Skip to content

Commit 2d88764

Browse files
authored
Merge pull request #77 from AutomateThePlanet/develop
Adding WebView2 support, changed screen recording logic and changed logic of attaching root element
2 parents 4e37498 + b4757b2 commit 2d88764

File tree

13 files changed

+780
-63
lines changed

13 files changed

+780
-63
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,8 @@ build/
140140

141141
# Ignore package-lock.json in favour of npm-shrinkwrap.json for npm publishing
142142
package-lock.json*
143+
144+
# Ignore downloaded chromedriver/edgedriver/ffmpeg binaries
145+
chromedriver/
146+
edgedriver/
147+
ffmpeg/

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,44 @@
1+
## [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)
2+
3+
### Features
4+
5+
* making screen recorder ffmpeg auto-downloadable and updated webview capability names ([095b2af](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/095b2af7d5a85dd757e92d5666d19ed2a20699b3))
6+
7+
### Bug Fixes
8+
9+
* changed logic of attaching the root window not working on some machines ([ee12870](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/ee12870d4f83a1b830c18b65f9dc1a7493825f96))
10+
* **webview:** fix current webview not being set ([a08f775](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/a08f775f935b90660e1229c1ed294822a93a024e))
11+
* **webview:** fix error when no webview endpoint is available ([c15567c](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/c15567c6d06db5b1581afd441c1a1eb7887ee1b8))
12+
13+
## [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)
14+
15+
### Bug Fixes
16+
17+
* **debug:** changed logic for finding window on app launch ([00edf24](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/00edf24d751672123eec0cb6db0b3c34a4bfd51c))
18+
19+
## [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)
20+
21+
### Bug Fixes
22+
23+
* fixed port cdpRequest issue ([37b9291](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/37b9291369c30ff2a39f7618d5d20d3b9725d9a0))
24+
25+
## [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)
26+
27+
### Bug Fixes
28+
29+
* debug cdp json issue ([fe3f762](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/fe3f762ae666fc9de5158f03c2b7c9acb62c52c0))
30+
31+
## [1.4.0-preview.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.1...v1.4.0-preview.1) (2026-04-02)
32+
33+
### Features
34+
35+
* **webview:** enable WebView2 support ([644034f](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/644034f866efcca48b87e0344b88d34f4471f608))
36+
37+
### Miscellaneous Chores
38+
39+
* **release:** 1.2.0-preview.1 [skip ci] ([f9cb5c5](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/f9cb5c5c8e7e88b7929171fe39c59635b721f4de))
40+
* **release:** 1.2.0-preview.2 [skip ci] ([6ad9bdb](https://github.com/AutomateThePlanet/appium-novawindows-driver/commit/6ad9bdbdbe26cb5c7f881a8e1e80f064dfc6863b))
41+
142
## [1.3.1](https://github.com/AutomateThePlanet/appium-novawindows-driver/compare/v1.3.0...v1.3.1) (2026-03-09)
243

344
### Bug Fixes

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ appWorkingDir | Optional working directory path for the application.
4848
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'}`
4949
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.
5050
isolatedScriptExecution | Whether PowerShell scripts are executed in an isolated session. Default is `false`.
51+
webviewEnabled | Whether to enable WebView support. Set to true to allow switching into WebView contexts. Default is `false`.
52+
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.
53+
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`.
54+
edgedriverCdnUrl | Base URL used to download EdgeDriver binaries for automating Edge (WebView2)-based WebViews in desktop applications. Defaults to `https://msedgedriver.microsoft.com`.
55+
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.
56+
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.
57+
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.
5158

5259
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.
5360

lib/commands/app.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { sleep } from '../util';
1717
import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver';
1818
import {
19-
getWindowAllHandlesForProcessIds,
19+
// getWindowAllHandlesForProcessIds,
2020
keyDown,
2121
keyUp,
2222
trySetForegroundWindow,
@@ -64,6 +64,24 @@ export async function getPageSource(this: NovaWindowsDriver): Promise<string> {
6464
return await this.sendPowerShellCommand(GET_PAGE_SOURCE_COMMAND.format(AutomationElement.automationRoot));
6565
}
6666

67+
export async function maximizeWindow(this: NovaWindowsDriver): Promise<void> {
68+
const automationRoot = new FoundAutomationElement(AutomationElement.automationRoot.buildGetPropertyCommand(Property.RUNTIME_ID));
69+
try {
70+
await this.sendPowerShellCommand(automationRoot.buildMaximizeCommand());
71+
} catch {
72+
throw new errors.UnknownError('Failed to maximize the window.');
73+
}
74+
}
75+
76+
export async function minimizeWindow(this: NovaWindowsDriver): Promise<void> {
77+
const automationRoot = new FoundAutomationElement(AutomationElement.automationRoot.buildGetPropertyCommand(Property.RUNTIME_ID));
78+
try {
79+
await this.sendPowerShellCommand(automationRoot.buildMinimizeCommand());
80+
} catch {
81+
throw new errors.UnknownError('Failed to minimize the window.');
82+
}
83+
}
84+
6785
export async function getScreenshot(this: NovaWindowsDriver): Promise<string> {
6886
const automationRootId = await this.sendPowerShellCommand(AutomationElement.automationRoot.buildCommand());
6987

@@ -287,17 +305,18 @@ export async function waitForNewWindow(this: NovaWindowsDriver, pid: number, tim
287305
let attempts = 0;
288306

289307
while (Date.now() - start < timeout) {
290-
const handles = getWindowAllHandlesForProcessIds([pid]);
291-
292-
if (handles.length > 0) {
293-
return handles[handles.length - 1];
308+
const elements = await this.sendPowerShellCommand(AutomationElement.rootElement.findAll(TreeScope.CHILDREN, new PropertyCondition(Property.PROCESS_ID, new PSInt32(pid))).buildCommand());
309+
const elementIds = elements.split('\n').map((id) => id.trim()).filter(Boolean);
310+
if (elementIds.length > 0) {
311+
const nativeWindowHandle = await this.sendPowerShellCommand(new FoundAutomationElement(elementIds[0]).buildGetPropertyCommand(Property.NATIVE_WINDOW_HANDLE));
312+
return Number(nativeWindowHandle);
294313
}
295314

296315
this.log.debug(`Waiting for the process window to appear... (${++attempts}/${Math.floor(timeout / SLEEP_INTERVAL_MS)})`);
297316
await sleep(SLEEP_INTERVAL_MS);
298317
}
299318

300-
throw new Error('Timed out waiting for window.');
319+
throw new errors.TimeoutError('Timed out waiting for window.');
301320
}
302321

303322
export async function attachToApplicationWindow(this: NovaWindowsDriver, processIds: number[]): Promise<void> {

lib/commands/contexts.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { Chromedriver, ChromedriverOpts } from 'appium-chromedriver';
2+
import { fs, node, system, tempDir, zip } from '@appium/support';
3+
import path from 'node:path';
4+
import { cdpRequest, downloadFile, sleep, MODULE_NAME } from '../util';
5+
import { NovaWindowsDriver } from '../driver';
6+
import { errors } from '@appium/base-driver';
7+
8+
const NATIVE_APP = 'NATIVE_APP';
9+
const WEBVIEW = 'WEBVIEW';
10+
const WEBVIEW_BASE = `${WEBVIEW}_`;
11+
12+
export async function getCurrentContext(this: NovaWindowsDriver): Promise<string> {
13+
return this.currentContext ??= NATIVE_APP;
14+
}
15+
16+
export async function setContext(this: NovaWindowsDriver, name?: string | null): Promise<void> {
17+
if (!name || name === NATIVE_APP) {
18+
this.chromedriver?.stop();
19+
this.chromedriver = null;
20+
this.jwpProxyActive = false;
21+
this.proxyReqRes = null;
22+
this.proxyCommand = null;
23+
this.currentContext = NATIVE_APP;
24+
return;
25+
}
26+
27+
const webViewDetails = await this.getWebViewDetails();
28+
29+
if (!(webViewDetails.pages ?? []).map((page) => page.id).includes(name.replace(WEBVIEW_BASE, ''))) {
30+
throw new errors.InvalidArgumentError(`Web view not found: ${name}`);
31+
}
32+
33+
const browser = webViewDetails.info?.Browser ?? '';
34+
const match = browser.match(/(Chrome|Edg)\/([\d.]+)/);
35+
36+
if (!match?.[1] || (match[1] !== 'Edg' && match[1] !== 'Chrome')) {
37+
throw new errors.InvalidArgumentError(`Unsupported browser type: ${match?.[1]}`);
38+
}
39+
40+
const browserType = match[1] === 'Edg' ? 'Edge' : 'Chrome';
41+
const browserVersion = match?.[2] ?? '';
42+
43+
const DRIVER_VERSION_REGEX = /^\d+(\.\d+){3}$/;
44+
if (!DRIVER_VERSION_REGEX.test(browserVersion)) {
45+
throw new errors.InvalidArgumentError(`Invalid browser version: ${browserVersion}`);
46+
}
47+
48+
this.log.debug(`Type: ${browserType}, Version: ${browserVersion}`);
49+
50+
const executable: string = await getDriverExecutable.call(this, browserType, browserVersion);
51+
52+
const chromedriverOpts: ChromedriverOpts & { details?: WebViewDetails } = {
53+
executable,
54+
details: webViewDetails,
55+
};
56+
57+
if (this.basePath) {
58+
chromedriverOpts.reqBasePath = this.basePath;
59+
}
60+
61+
const cd = new Chromedriver(chromedriverOpts);
62+
this.chromedriver = cd;
63+
64+
const page = webViewDetails.pages?.find((p) => p.id === name.replace(WEBVIEW_BASE, ''));
65+
66+
const debuggerAddress = (page?.webSocketDebuggerUrl ?? '')
67+
.replace('ws://', '')
68+
.split('/')[0];
69+
70+
const options = { debuggerAddress };
71+
72+
const caps = {
73+
'ms:edgeOptions': options,
74+
'goog:chromeOptions': options,
75+
};
76+
77+
this.currentContext = name;
78+
await this.chromedriver.start(caps);
79+
this.log.debug('Chromedriver started. Session ID:', cd.sessionId());
80+
81+
this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver);
82+
this.proxyCommand = this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy);
83+
this.jwpProxyActive = true;
84+
}
85+
86+
export async function getContexts(this: NovaWindowsDriver): Promise<string[]> {
87+
const webViewDetails = await this.getWebViewDetails();
88+
return [
89+
NATIVE_APP,
90+
...(webViewDetails.pages?.map((page) => `${WEBVIEW_BASE}${page.id}`) ?? []),
91+
];
92+
}
93+
94+
export interface WebViewDetails {
95+
/**
96+
* Web view details as returned by /json/version CDP endpoint
97+
* @example
98+
* {
99+
* "Browser": "Edg/145.0.3800.97",
100+
* "Protocol-Version": "1.3",
101+
* "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",
102+
* "V8-Version": "14.5.40.9",
103+
* "WebKit-Version": "537.36 (@f4c49d5241f148220b99eb7f045ac370a1694a15)",
104+
* "webSocketDebuggerUrl": "ws://localhost:10900/devtools/browser/7039e1b9-f75d-44eb-8583-7279c107bb18"
105+
* }
106+
*/
107+
info?: CDPVersionResponse;
108+
109+
/**
110+
* Web view details as returned by /json/list CDP endpoint
111+
* @example // TODO: change example to not include Spotify / use mock data
112+
* [ {
113+
* "description": "",
114+
* "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@80be69ef794ba862ff256b0b23f051cbbc32e1ed/inspector.html?ws=localhost:9222/devtools/page/21C6035BC3E0A67D0BB6AE10F4A66D4A",
115+
* "faviconUrl": "https://xpui.app.spotify.com/favicon.ico",
116+
* "id": "21C6035BC3E0A67D0BB6AE10F4A66D4A",
117+
* "title": "Spotify - Web Player: Music for everyone",
118+
* "type": "page",
119+
* "url": "https://xpui.app.spotify.com/index.html",
120+
* "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/21C6035BC3E0A67D0BB6AE10F4A66D4A"
121+
* }, {
122+
* "description": "",
123+
* "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@80be69ef794ba862ff256b0b23f051cbbc32e1ed/inspector.html?ws=localhost:9222/devtools/page/7E7008B3C464CD91224ADF976115101F",
124+
* "id": "7E7008B3C464CD91224ADF976115101F",
125+
* "title": "",
126+
* "type": "worker",
127+
* "url": "",
128+
* "webSocketDebuggerUrl": "ws://localhost:10900/devtools/page/7E7008B3C464CD91224ADF976115101F"
129+
* } ]
130+
*/
131+
pages?: CDPListResponse;
132+
}
133+
134+
interface CDPVersionResponse {
135+
'Browser': string,
136+
'Protocol-Version': string,
137+
'User-Agent': string,
138+
'V8-Version': string,
139+
'WebKit-Version': string,
140+
'webSocketDebuggerUrl': string,
141+
}
142+
143+
interface CDPListResponseEntry {
144+
'description': string,
145+
'devtoolsFrontendUrl': string,
146+
'faviconUrl': string,
147+
'id': string,
148+
'title': string,
149+
'type': string,
150+
'url': string,
151+
'webSocketDebuggerUrl': string,
152+
}
153+
154+
type CDPListResponse = CDPListResponseEntry[];
155+
156+
export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewMs?: number): Promise<WebViewDetails> {
157+
if (!this.caps.webviewEnabled) {
158+
throw new errors.InvalidArgumentError('WebView support is not enabled. Please set the "enableWebView" capability to true and try again.');
159+
}
160+
161+
this.log.debug(`Getting a list of available webviews`);
162+
163+
const waitMs = waitForWebviewMs ? Number(waitForWebviewMs) : 0;
164+
if (waitMs) {
165+
this.log.debug(`waiting for ${waitMs} ms`);
166+
await sleep(waitMs);
167+
}
168+
169+
const host = 'localhost';
170+
171+
if ((this.caps.app === 'none' || this.caps.app === 'root' || this.caps.appTopLevelWindow != null) && this.caps.webviewDevtoolsPort == null) {
172+
throw new errors.InvalidArgumentError(`Capability "webviewDevtoolsPort" must be set when using "none", "root", or "appTopLevelWindow" with "enableWebView"`);
173+
}
174+
175+
const port = this.webviewDevtoolsPort ??= this.caps.webviewDevtoolsPort ?? null;
176+
177+
const info = await (cdpRequest.call(this, ({ host, port, endpoint: '/json/version', timeout: 10000 })) as Promise<CDPVersionResponse>).catch(() => undefined);
178+
const pages = await (cdpRequest.call(this, ({ host, port, endpoint: '/json/list', timeout: 10000 })) as Promise<CDPListResponse>).catch(() => undefined);
179+
180+
const webViewDetails: WebViewDetails = { info, pages };
181+
182+
return webViewDetails;
183+
}
184+
185+
async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' | 'Chrome', browserVersion: `${number}.${number}.${number}.${number}`): Promise<string> {
186+
let driverType: string;
187+
188+
if (browserType === 'Chrome') {
189+
driverType = 'chromedriver';
190+
} else {
191+
driverType = 'edgedriver';
192+
}
193+
194+
const root = node.getModuleRootSync(MODULE_NAME, __filename);
195+
if (!root) {
196+
throw new errors.InvalidArgumentError(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`);
197+
}
198+
199+
const driverDir = path.join(root, driverType);
200+
201+
if (!(await fs.exists(driverDir))) {
202+
await fs.mkdir(driverDir);
203+
}
204+
205+
const fileName = browserType === 'Edge' ? 'msedgedriver.exe' : 'chromedriver.exe';
206+
const finalPath = path.join(driverDir, browserVersion, fileName);
207+
208+
if (await fs.exists(finalPath)) {
209+
return finalPath;
210+
}
211+
212+
const executablePath = browserType === 'Edge'
213+
? this.caps.edgedriverExecutablePath
214+
: this.caps.chromedriverExecutablePath;
215+
216+
if (executablePath) {
217+
const exists = await fs.exists(executablePath);
218+
if (!exists) {
219+
throw new errors.InvalidArgumentError(`Driver executable not found at path: ${executablePath}`);
220+
}
221+
222+
this.log.debug(
223+
`Using local ${browserType} driver executable at ${executablePath}. ` +
224+
`Automatic download is disabled and CDN URLs are ignored. ` +
225+
`You must ensure this binary matches the WebView/Chromium version (${browserVersion}).`
226+
);
227+
228+
return executablePath;
229+
}
230+
231+
const arch = await system.arch();
232+
const zipFilename = `${driverType}${browserType === 'Edge' ? '_' : '-'}win${arch}.zip`;
233+
234+
const CHROME_BASE_URL = this.caps.chromedriverCdnUrl || 'https://storage.googleapis.com/chrome-for-testing-public';
235+
const EDGE_BASE_URL = this.caps.edgedriverCdnUrl || 'https://msedgedriver.microsoft.com';
236+
237+
let downloadUrl = '';
238+
239+
if (browserType === 'Chrome') {
240+
const url = new URL(CHROME_BASE_URL);
241+
url.pathname = path.posix.join(url.pathname, browserVersion, `win${arch}`, zipFilename);
242+
downloadUrl = url.toString();
243+
} else {
244+
const url = new URL(EDGE_BASE_URL);
245+
url.pathname = path.posix.join(url.pathname, browserVersion, zipFilename);
246+
downloadUrl = url.toString();
247+
}
248+
249+
this.log.debug(`Downloading ${browserType} driver version ${browserVersion}...`);
250+
const tmpRoot = await tempDir.openDir();
251+
await downloadFile(downloadUrl, tmpRoot);
252+
253+
try {
254+
await zip.extractAllTo(path.join(tmpRoot, zipFilename), tmpRoot);
255+
256+
const driverPath = await fs.walkDir(
257+
tmpRoot,
258+
true,
259+
(itemPath, isDirectory) => !isDirectory && path.parse(itemPath).base.toLowerCase() === fileName);
260+
261+
if (!driverPath) {
262+
throw new errors.UnknownError(`The archive was unzipped properly, but did not find any ${driverType} executable.`);
263+
}
264+
265+
this.log.debug(`Moving the extracted '${fileName}' to '${finalPath}'`);
266+
await fs.mv(driverPath, finalPath, { mkdirp: true });
267+
} finally {
268+
await fs.rimraf(tmpRoot);
269+
}
270+
271+
return finalPath;
272+
}

0 commit comments

Comments
 (0)