Skip to content

Commit ee0314d

Browse files
authored
Merge pull request #71 from AutomateThePlanet/feature/webview-support
feat(webview): enable WebView2 support
2 parents 578e07e + 644034f commit ee0314d

File tree

9 files changed

+410
-6
lines changed

9 files changed

+410
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ 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+
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.
5152

5253
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.
5354

lib/commands/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ export async function waitForNewWindow(this: NovaWindowsDriver, pid: number, tim
297297
await sleep(SLEEP_INTERVAL_MS);
298298
}
299299

300-
throw new Error('Timed out waiting for window.');
300+
throw new errors.TimeoutError('Timed out waiting for window.');
301301
}
302302

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

lib/commands/contexts.ts

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

lib/commands/extension.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { W3C_ELEMENT_KEY, errors } from '@appium/base-driver';
1+
import { PROTOCOLS, W3C_ELEMENT_KEY, errors } from '@appium/base-driver';
22
import { Element, Rect } from '@appium/types';
33
import { tmpdir } from 'node:os';
44
import { extname, join } from 'node:path';
@@ -119,11 +119,29 @@ export async function execute(this: NovaWindowsDriver, script: string, args: any
119119
return await this[EXTENSION_COMMANDS[script]](...args);
120120
}
121121

122+
if (script === 'mobile:getContexts') {
123+
if (!this.caps.enableWebView) {
124+
throw new errors.InvalidArgumentError('WebView support is not enabled. To use this command, enable WebView support by setting the "enableWebView" capability to true.');
125+
}
126+
const { waitForWebviewMs }: { waitForWebviewMs?: number } = args[0] || {};
127+
const webViewDetails = await this.getWebViewDetails(waitForWebviewMs);
128+
return [{
129+
id: 'NATIVE_APP',
130+
}, ...(webViewDetails.pages ?? [])];
131+
}
132+
122133
if (script === 'powerShell') {
123134
this.assertFeatureEnabled(POWER_SHELL_FEATURE);
124135
return await this.executePowerShellScript(args[0]);
125136
}
126137

138+
if (this.chromedriver && this.proxyActive()) {
139+
const endpoint = this.chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP
140+
? '/execute'
141+
: '/execute/sync';
142+
return await this.chromedriver.jwproxy.command(endpoint, 'POST', { script, args });
143+
}
144+
127145
if (script === 'return window.name') {
128146
return await this.sendPowerShellCommand(AutomationElement.automationRoot.buildGetPropertyCommand(Property.NAME));
129147
}
@@ -425,7 +443,7 @@ export async function executeClick(this: NovaWindowsDriver, clickArgs: {
425443
pos = [x!, y!];
426444
}
427445

428-
const clickTypeToButtonMapping: { [key in ClickType]: number} = {
446+
const clickTypeToButtonMapping: { [key in ClickType]: number } = {
429447
[ClickType.LEFT]: 0,
430448
[ClickType.MIDDLE]: 1,
431449
[ClickType.RIGHT]: 2,

lib/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as extension from './extension';
55
import * as device from './device';
66
import * as system from './system';
77
import * as app from './app';
8+
import * as contexts from './contexts';
89

910
const commands = {
1011
...actions,
@@ -14,6 +15,7 @@ const commands = {
1415
...system,
1516
...device,
1617
...app,
18+
...contexts,
1719
// add the rest of the commands here
1820
};
1921

0 commit comments

Comments
 (0)