appium-lg-webos-driver
Version:
LG WebOS support for Appium
613 lines (538 loc) • 18.9 kB
JavaScript
import {BaseDriver, errors} from 'appium/driver';
import B from 'bluebird';
import _ from 'lodash';
import {
closeApp,
extendDevMode,
getDeviceInfo,
installApp,
launchApp,
uninstallApp,
} from './cli/ares';
import {CAP_CONSTRAINTS, DEFAULT_CAPS} from './constraints';
import {AsyncScripts, SyncScripts} from './scripts';
// @ts-ignore
import Chromedriver from 'appium-chromedriver';
import getPort from 'get-port';
import got from 'got';
import {KEYMAP} from './keys';
import log from './logger';
import {LGRemoteKeys} from './remote/lg-remote-client';
import {LGWSClient} from './remote/lg-socket-client';
// eslint-disable-next-line import/no-unresolved
import {ValueBox} from './remote/valuebox';
export {KEYS} from './keys';
// this is the ID for the 'Developer' application, which we launch after a session ends to ensure
// some app stays running (otherwise the TV might shut off)
const DEV_MODE_ID = 'com.palmdts.devmode';
/**
* A security flag to enable chromedriver auto download feature
*/
const CHROMEDRIVER_AUTODOWNLOAD_FEATURE = 'chromedriver_autodownload';
/**
* To get chrome driver version in the UA
*/
const REGEXP_CHROME_VERSION_IN_UA = new RegExp('Chrome\\/(\\S+)');
/**
* To get chrome version from the browser info.
*/
const VERSION_PATTERN = /([\d.]+)/;
/**
* Minimal chrome browser for autodownload.
* Chromedriver for older than this chrome version could have an issue
* to raise no chrome binary error.
*/
const MIN_CHROME_MAJOR_VERSION = 63;
const MIN_CHROME_VERSION = 'Chrome/63.0.3239.0';
// don't proxy any 'appium' routes
/** @type {RouteMatcher[]} */
const NO_PROXY = [
['POST', new RegExp('^/session/[^/]+/appium')],
['GET', new RegExp('^/session/[^/]+/appium')],
['GET', new RegExp('^/session/[^/]+/context')],
['POST', new RegExp('^/session/[^/]+/execute/sync')],
];
export const DEFAULT_PRESS_DURATION_MS = 100;
/**
* @extends {BaseDriver<WebOsConstraints>}
*/
export class WebOSDriver extends BaseDriver {
/** @type {RouteMatcher[]} */
jwpProxyAvoid = _.clone(NO_PROXY); // why clone?
/** @type {boolean} */
jwpProxyActive = false;
/** @type {LGWSClient|undefined} */
socketClient;
/** @type {import('./remote/lg-remote-client').LGRemoteClient|undefined} */
remoteClient;
desiredCapConstraints = CAP_CONSTRAINTS;
/** @type {Chromedriver|undefined} */
#chromedriver;
static executeMethodMap = {
'webos: pressKey': Object.freeze({
command: 'pressKey',
params: {required: ['key'], optional: ['duration']},
}),
'webos: listApps': Object.freeze({
command: 'listApps'
}),
'webos: activeAppInfo': Object.freeze({
command: 'getCurrentForegroundAppInfo'
}),
};
/**
*
* @param {any} name
* @returns {name is ScriptId}
*/
static isExecuteScript(name) {
return name in WebOSDriver.executeMethodMap;
}
/**
* @param {W3CWebOsCaps} w3cCaps1
* @param {W3CWebOsCaps} w3cCaps2
* @param {W3CWebOsCaps} w3cCaps3
* @returns {Promise<[string,WebOsCaps]>}
*/
async createSession(w3cCaps1, w3cCaps2, w3cCaps3) {
w3cCaps3.alwaysMatch = {...DEFAULT_CAPS, ...w3cCaps3.alwaysMatch};
let [sessionId, caps] = await super.createSession(w3cCaps1, w3cCaps2, w3cCaps3);
const {
autoExtendDevMode,
deviceName,
app,
appId,
appLaunchParams,
noReset,
fullReset,
deviceHost,
debuggerPort,
showChromedriverLog,
chromedriverExecutable,
chromedriverExecutableDir,
appLaunchCooldown,
remoteOnly,
websocketPort,
websocketPortSecure,
useSecureWebsocket,
keyCooldown,
} = caps;
if (noReset && fullReset) {
throw new Error(`Cannot use both noReset and fullReset`);
}
if (autoExtendDevMode) {
await extendDevMode(deviceName);
}
try {
caps.deviceInfo = await getDeviceInfo(deviceName);
} catch (error) {
throw new Error(
`Could not retrieve device info for device with ` +
`name '${deviceName}'. Are you sure the device is ` +
`connected? (Original error: ${error})`
);
}
if (fullReset) {
try {
await uninstallApp(appId, deviceName);
} catch (err) {
// if the app is not installed, we expect the following error message, so if we get any
// message other than that one, bubble the error up. Otherwise, just ignore!
if (!/FAILED_REMOVE/.test(/** @type {Error} */ (err).message)) {
throw err;
}
}
}
if (app) {
await installApp(app, appId, deviceName);
}
this.valueBox = ValueBox.create('appium-lg-webos-driver');
this.socketClient = new LGWSClient({
valueBox: this.valueBox,
deviceName,
url: `ws://${deviceHost}:${websocketPort}`,
urlSecure: `wss://${deviceHost}:${websocketPortSecure}`,
useSecureWebsocket,
remoteKeyCooldown: keyCooldown,
});
log.info(`Connecting remote; address any prompts on screen now!`);
await this.socketClient.initialize();
this.remoteClient = await this.socketClient.getRemoteClient();
await launchApp(appId, deviceName, appLaunchParams);
const waitMsgInterval = setInterval(() => {
log.info('Waiting for app launch to take effect');
}, 1000);
await B.delay(appLaunchCooldown);
clearInterval(waitMsgInterval);
if (remoteOnly) {
log.info(`Remote-only mode requested, not starting chromedriver`);
// in remote-only mode, we force rcMode to 'rc' instead of 'js'
this.opts.rcMode = caps.rcMode = 'rc';
return [sessionId, caps];
}
await this.startChromedriver({
debuggerHost: deviceHost,
debuggerPort,
executable: /** @type {string} */ (chromedriverExecutable),
executableDir: /** @type {string} */ (chromedriverExecutableDir),
isAutodownloadEnabled: /** @type {Boolean} */ (this.#isChromedriverAutodownloadEnabled()),
verbose: /** @type {Boolean | undefined} */ (showChromedriverLog),
});
log.info('Waiting for app launch to take effect');
await B.delay(appLaunchCooldown);
if (!noReset) {
log.info('Clearing app local storage & reloading');
await this.executeChromedriverScript(SyncScripts.reset);
}
return [sessionId, caps];
}
/**
* @typedef BrowserVersionInfo
* @property {string} Browser
* @property {string} Protocol-Version
* @property {string} User-Agent
* @property {string} [V8-Version]
* @property {string} WebKit-Version
* @property {string} [webSocketDebuggerUrl]
*/
/**
* Use UserAgent info for "Browser" if the chrome response did not include
* browser name properly.
* @param {BrowserVersionInfo} browserVersionInfo
* @return {BrowserVersionInfo}
*/
useUAForBrowserIfNotPresent(browserVersionInfo) {
if (!_.isEmpty(browserVersionInfo.Browser)) {
return browserVersionInfo;
}
const ua = browserVersionInfo['User-Agent'];
if (_.isEmpty(ua)) {
return browserVersionInfo;
}
const chromeVersion = ua.match(REGEXP_CHROME_VERSION_IN_UA);
if (_.isEmpty(chromeVersion)) {
return browserVersionInfo;
}
log.info(`The response did not have Browser, thus set the Browser value from UA as ${JSON.stringify(browserVersionInfo)}`);
// @ts-ignore isEmpty already checked as null.
browserVersionInfo.Browser = chromeVersion[0];
return browserVersionInfo;
}
/**
* Set chrome version v63.0.3239.0 as the minimal version
* for autodownload to use proper chromedriver version if
* - the 'Browser' info does not have proper chrome version, or
* - older than the chromedriver version could raise no Chrome binary found error,
* which no makes sense for TV automation usage.
*
* @param {BrowserVersionInfo} browserVersionInfo
* @return {BrowserVersionInfo}
*/
fixChromeVersionForAutodownload(browserVersionInfo) {
const chromeVersion = VERSION_PATTERN.exec(browserVersionInfo.Browser ?? '');
if (!chromeVersion) {
browserVersionInfo.Browser = MIN_CHROME_VERSION;
return browserVersionInfo;
}
const majorV = chromeVersion[1].split('.')[0];
if (_.toInteger(majorV) < MIN_CHROME_MAJOR_VERSION) {
log.info(`The device chrome version is ${chromeVersion[1]}, ` +
`which could cause an issue for the matched chromedriver version. ` +
`Setting ${MIN_CHROME_VERSION} as browser forcefully`);
browserVersionInfo.Browser = MIN_CHROME_VERSION;
}
return browserVersionInfo;
}
/**
* Returns whether the session can enable autodownloadd feature.
* @returns {boolean}
*/
#isChromedriverAutodownloadEnabled() {
if (this.isFeatureEnabled(CHROMEDRIVER_AUTODOWNLOAD_FEATURE)) {
return true;
}
this.log.debug(
`Automated Chromedriver download is disabled. ` +
`Use '${CHROMEDRIVER_AUTODOWNLOAD_FEATURE}' server feature to enable it`,
);
return false;
}
/**
* @param {StartChromedriverOptions} opts
*/
async startChromedriver({debuggerHost, debuggerPort, executable, executableDir, isAutodownloadEnabled, verbose}) {
const debuggerAddress = `${debuggerHost}:${debuggerPort}`;
let result;
if (executableDir) {
// get the result of chrome info to use auto detection.
try {
result = await got.get(`http://${debuggerAddress}/json/version`).json();
log.info(`The response of http://${debuggerAddress}/json/version was ${JSON.stringify(result)}`);
result = this.useUAForBrowserIfNotPresent(result);
result = this.fixChromeVersionForAutodownload(result);
log.info(`Fixed browser info is ${JSON.stringify(result)}`);
// To respect the executableDir.
executable = undefined;
if (_.isEmpty(result.Browser)) {
this.log.info(`No browser version info was available. If no proper chromedrivers exist in ${executableDir}, the session creation will fail.`);
}
} catch (err) {
throw new errors.SessionNotCreatedError(
`Could not get the chrome browser information to detect proper chromedriver version. Is it a debuggable build? Error: ${err.message}`
);
}
}
this.#chromedriver = new Chromedriver({
// @ts-ignore bad types
port: await getPort(),
executable,
executableDir,
isAutodownloadEnabled,
// @ts-ignore
details: {info: result},
verbose
});
// XXX: goog:chromeOptions in newer versions, chromeOptions in older
await this.#chromedriver.start({
chromeOptions: {
debuggerAddress,
},
});
this.proxyReqRes = this.#chromedriver.proxyReq.bind(this.#chromedriver);
this.jwpProxyActive = true;
}
/**
* Execute some arbitrary JS via Chromedriver.
* @template [TReturn=any]
* @template [TArg=any]
* @param {((...args: any[]) => TReturn)|string} script
* @param {TArg[]} [args]
* @returns {Promise<{value: TReturn}>}
*/
async executeChromedriverScript(script, args = []) {
return await this.#executeChromedriverScript('/execute/sync', script, args);
}
/**
* Given a script of {@linkcode ScriptId} or some arbitrary JS, figure out
* which it is and run it.
*
* @template [TArg=any]
* @template [TReturn=unknown]
* @template {import('type-fest').LiteralUnion<ScriptId, string>} [S=string]
* @param {S} script
* @param {S extends ScriptId ? [Record<string,any>] : TArg[]} args
* @returns {Promise<S extends ScriptId ? import('type-fest').AsyncReturnType<ExecuteMethod<S>> : {value: TReturn}>}
*/
async execute(script, args) {
if (WebOSDriver.isExecuteScript(script)) {
log.debug(`Calling script "${script}" with arg ${JSON.stringify(args[0])}`);
const methodArgs = /** @type {[Record<string,any>]} */ (args);
return await this.executeMethod(script, [methodArgs[0]]);
}
return await /** @type {Promise<S extends ScriptId ? import('type-fest').AsyncReturnType<ExecuteMethod<S>> : {value: TReturn}>} */ (
this.executeChromedriverScript(script, /** @type {TArg[]} */ (args))
);
}
/**
*
* @param {string} sessionId
* @param {import('@appium/types').DriverData[]} [driverData]
*/
async deleteSession(sessionId, driverData) {
// TODO decide if we want to extend at the end of the session too
//if (this.opts.autoExtendDevMode) {
//await extendDevMode(this.opts.deviceName);
//}
if (this.#chromedriver) {
log.debug(`Stopping chromedriver`);
// stop listening for the stopped state event
// @ts-ignore
this.#chromedriver.removeAllListeners(Chromedriver.EVENT_CHANGED);
try {
await this.#chromedriver.stop();
} catch (err) {
log.warn(`Error stopping Chromedriver: ${/** @type {Error} */ (err).message}`);
}
this.#chromedriver = undefined;
}
try {
await closeApp(this.opts.appId, this.opts.deviceName);
} catch (err) {
log.warn(`Error in closing ${this.opts.appId}: ${/** @type {Error} */ (err).message}`);
}
if (this.remoteClient) {
log.info(`Pressing HOME and launching dev app to prevent auto off`);
await this.remoteClient.pressKey(LGRemoteKeys.HOME);
await launchApp(DEV_MODE_ID, this.opts.deviceName);
}
if (this.socketClient) {
log.debug(`Stopping socket clients`);
try {
await this.socketClient.disconnect();
} catch (err) {
log.warn(`Error stopping socket clients: ${err}`);
}
this.socketClient = undefined;
this.remoteClient = undefined;
}
await super.deleteSession(sessionId, driverData);
}
proxyActive() {
return this.jwpProxyActive;
}
getProxyAvoidList() {
return this.jwpProxyAvoid;
}
canProxy() {
return true;
}
/**
* Execute some arbitrary JS via Chromedriver.
* @template [TReturn=unknown]
* @template [TArg=any]
* @param {string} endpointPath - Relative path of the endpoint URL
* @param {((...args: any[]) => TReturn)|string} script
* @param {TArg[]} [args]
* @returns {Promise<{value: TReturn}>}
*/
async #executeChromedriverScript(endpointPath, script, args = []) {
const wrappedScript =
typeof script === 'string' ? script : `return (${script}).apply(null, arguments)`;
// @ts-ignore
return await this.#chromedriver.sendCommand(endpointPath, 'POST', {
script: wrappedScript,
args,
});
}
/**
* Automates a keypress
* @param {import('./keys').KnownKey} key
* @param {number} [duration]
*/
async pressKey(key, duration) {
if (this.opts.rcMode === 'js') {
return await this.#pressKeyViaJs(key, duration);
} else {
if (duration) {
this.log.warn(
`Attempted to send a duration for a remote-based ` + `key press; duration will be ignored`
);
}
return await this.pressKeyViaRemote(key);
}
}
/**
* Automates a press of a button on a remote control.
* @param {string} key
*/
async pressKeyViaRemote(key) {
const sc = /** @type {import('./remote/lg-socket-client').LGWSClient} */ (this.socketClient);
const rc = /** @type {import('./remote/lg-remote-client').LGRemoteClient} */ (
this.remoteClient
);
const keyMap = Object.freeze(
/** @type {const} */ ({
VOL_UP: sc.volumeUp,
VOL_DOWN: sc.volumeDown,
MUTE: sc.mute,
UNMUTE: sc.unmute,
PLAY: sc.play,
STOP: sc.stop,
REWIND: sc.rewind,
FF: sc.fastForward,
CHAN_UP: sc.channelUp,
CHAN_DOWN: sc.channelDown,
})
);
/**
*
* @param {any} key
* @returns {key is keyof typeof keyMap}
*/
const isMappedKey = (key) => key in keyMap;
const knownKeys = [...Object.keys(keyMap), ...Object.keys(LGRemoteKeys)];
if (!knownKeys.includes(_.upperCase(key))) {
this.log.warn(`Unknown key '${key}'; will send to remote as-is`);
return await rc.pressKey(key);
}
key = _.upperCase(key);
if (isMappedKey(key)) {
this.log.info(`Found virtual 'key' to be sent as socket command`);
return await keyMap[key].call(sc);
}
return await rc.pressKey(key);
}
/**
* Press key via Chromedriver.
* @param {import('./keys').KnownKey} key
* @param {number} [duration]
*/
async #pressKeyViaJs(key, duration = DEFAULT_PRESS_DURATION_MS) {
key = /** @type {typeof key} */ (key.toLowerCase());
const [keyCode, keyName] = KEYMAP[key];
if (!keyCode) {
throw new errors.InvalidArgumentError(`Key name '${key}' is not supported`);
}
await this.#executeChromedriverScript('/execute/sync', AsyncScripts.pressKey, [
keyCode,
keyName,
duration,
]);
}
/**
*
* @returns {Promise<[object]>} Return the list of installed applications
*/
async listApps() {
const sc = /** @type {import('./remote/lg-socket-client').LGWSClient} */ (this.socketClient);
if (sc) {
return (await sc.getListApps()).apps;
};
throw new errors.UnknownError('Socket connection to the device might be missed');
}
/**
*
* @returns {Promise<object>} Return current active application information.
*/
async getCurrentForegroundAppInfo() {
const sc = /** @type {import('./remote/lg-socket-client').LGWSClient} */ (this.socketClient);
if (sc) {
// {"returnValue"=>true, "appId"=>"com.your.app", "processId"=>"", "windowId"=>""}
return await sc.getForegroundAppInfo();
};
throw new errors.UnknownError('Socket connection to the device might be missed');
}
/**
* A dummy implementation to return 200 ok with NATIVE_APP context for
* webdriverio compatibility. https://github.com/headspinio/appium-roku-driver/issues/175
*
* @returns {Promise<string>}
*/
// eslint-disable-next-line require-await
async getCurrentContext() {
return 'NATIVE_APP';
}
}
/**
* @typedef {import('./types').ExtraWebOsCaps} WebOSCapabilities
* @typedef {import('./constraints').WebOsConstraints} WebOsConstraints
* @typedef {import('./keys').KnownKey} Key
* @typedef {import('./types').StartChromedriverOptions} StartChromedriverOptions
*/
/**
* @typedef {import('@appium/types').DriverCaps<WebOsConstraints, WebOSCapabilities>} WebOsCaps
* @typedef {import('@appium/types').W3CDriverCaps<WebOsConstraints, WebOSCapabilities>} W3CWebOsCaps
* @typedef {import('@appium/types').RouteMatcher} RouteMatcher
*/
/**
* @typedef {typeof WebOSDriver.executeMethodMap} WebOSDriverExecuteMethodMap
*/
/**
* A known script identifier (e.g., `tizen: pressKey`)
* @typedef {keyof WebOSDriverExecuteMethodMap} ScriptId
*/
/**
* Lookup a method by its script ID.
* @template {ScriptId} S
* @typedef {WebOSDriver[WebOSDriverExecuteMethodMap[S]['command']]} ExecuteMethod
*/