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/CHANGELOG.md b/CHANGELOG.md index 9b1e745..591069c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +## [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 + +* **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 + +* 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 + +* 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 + +* **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 diff --git a/README.md b/README.md index 7cdc3f9..13c5549 100644 --- a/README.md +++ b/README.md @@ -48,6 +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`. +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 e82d5ed..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, @@ -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()); @@ -287,17 +305,18 @@ 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) { + 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)})`); 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..68d697a --- /dev/null +++ b/lib/commands/contexts.ts @@ -0,0 +1,272 @@ +import { Chromedriver, ChromedriverOpts } from 'appium-chromedriver'; +import { fs, node, system, tempDir, zip } from '@appium/support'; +import path from 'node:path'; +import { cdpRequest, downloadFile, sleep, MODULE_NAME } from '../util'; +import { NovaWindowsDriver } from '../driver'; +import { errors } from '@appium/base-driver'; + +const NATIVE_APP = 'NATIVE_APP'; +const WEBVIEW = 'WEBVIEW'; +const WEBVIEW_BASE = `${WEBVIEW}_`; + +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, + }; + + this.currentContext = name; + 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.webviewEnabled) { + 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) { + this.log.debug(`waiting for ${waitMs} ms`); + await sleep(waitMs); + } + + const host = 'localhost'; + + 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 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; +} + +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); + } + + 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') { + 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, + (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; +} diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index f67d559..9280c2d 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.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] || {}; + 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, @@ -702,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/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..43f6020 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/commands/screen-recorder.ts b/lib/commands/screen-recorder.ts index db666c0..856a997 100644 --- a/lib/commands/screen-recorder.ts +++ b/lib/commands/screen-recorder.ts @@ -1,8 +1,8 @@ -import type { AppiumLogger } from '@appium/types'; -import { fs, net, system, util } from 'appium/support'; +import { fs, net, util } from 'appium/support'; import { waitForCondition } from 'asyncbox'; -import { SubProcess } from 'teen_process'; +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { getBundledFfmpegPath } from '../util'; +import { NovaWindowsDriver } from '../driver'; const RETRY_PAUSE = 300; const RETRY_TIMEOUT = 5000; @@ -32,12 +32,12 @@ export interface UploadOptions { formFields?: Array<[string, string]> | 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 e2f82d6..8b493cb 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -42,6 +42,27 @@ export const UI_AUTOMATION_DRIVER_CONSTRAINTS = { 'ms:forcequit': { isBoolean: true, }, + webviewEnabled: { + isBoolean: true, + }, + 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/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/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 619b160..ddb5efc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-novawindows-driver", - "version": "1.3.1", + "version": "1.4.0-preview.5", "description": "Appium driver for Windows", "keywords": [ "appium", @@ -32,8 +32,8 @@ }, "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" }, @@ -60,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" } }