diff --git a/.github/dependabot.yml b/.github/dependabot.yml index efc8b6d5..3a0a862f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,12 @@ updates: commit-message: prefix: "chore" include: "scope" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + time: "11:00" + open-pull-requests-limit: 10 + commit-message: + prefix: "chore" + include: "scope" diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 51c3a506..929df133 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -3,14 +3,8 @@ on: pull_request: types: [opened, edited, synchronize, reopened] - jobs: lint: - name: https://www.conventionalcommits.org - runs-on: ubuntu-latest - steps: - - uses: beemojs/conventional-pr-action@v3 - with: - config-preset: angular - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: appium/appium-workflows/.github/workflows/pr-title.yml@main + with: + config-preset: angular diff --git a/.github/workflows/publish.js.yml b/.github/workflows/publish.js.yml index 6c0796af..d4552542 100644 --- a/.github/workflows/publish.js.yml +++ b/.github/workflows/publish.js.yml @@ -20,7 +20,10 @@ jobs: uses: actions/setup-node@v3 with: node-version: lts/* - - run: npm install --no-package-lock + - uses: SocketDev/action@v1 + with: + mode: firewall-free + - run: sfw npm install --no-package-lock name: Install dependencies - run: npm run test name: Run NPM Test @@ -37,4 +40,3 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} name: Release - diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index b315a8b5..a6b56479 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -4,28 +4,25 @@ on: [pull_request, push] jobs: - prepare_matrix: - runs-on: ubuntu-latest - outputs: - versions: ${{ steps.generate-matrix.outputs.versions }} - steps: - - name: Select 3 most recent LTS versions of Node.js - id: generate-matrix - run: echo "versions=$(curl -s https://endoflife.date/api/nodejs.json | jq -c '[[.[] | select(.lts != false)][:3] | .[].cycle | tonumber]')" >> "$GITHUB_OUTPUT" + node_matrix: + uses: appium/appium-workflows/.github/workflows/node-lts-matrix.yml@main test: needs: - - prepare_matrix + - node_matrix strategy: matrix: - node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }} - runs-on: windows-latest + node-version: ${{ fromJSON(needs.node_matrix.outputs.versions) }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - - run: npm install --no-package-lock + - uses: SocketDev/action@v1 + with: + mode: firewall-free + - run: sfw npm install --no-package-lock name: Install dev dependencies - run: npm run lint name: Run linter diff --git a/lib/constants.js b/lib/constants.ts similarity index 100% rename from lib/constants.js rename to lib/constants.ts diff --git a/lib/desired-caps.ts b/lib/desired-caps.ts index 9bae8713..eefe04ac 100644 --- a/lib/desired-caps.ts +++ b/lib/desired-caps.ts @@ -1,3 +1,5 @@ +import type {Constraints} from '@appium/types'; + export const desiredCapConstraints = { // https://github.com/microsoft/WinAppDriver/blob/master/Docs/AuthoringTestScripts.md#supported-capabilities platformName: { @@ -47,6 +49,4 @@ export const desiredCapConstraints = { wadUrl: { isString: true }, -} as const; - -export default desiredCapConstraints; +} as const satisfies Constraints; diff --git a/lib/driver.js b/lib/driver.ts similarity index 70% rename from lib/driver.js rename to lib/driver.ts index d5c017ec..021dfa1d 100644 --- a/lib/driver.js +++ b/lib/driver.ts @@ -1,7 +1,20 @@ import _ from 'lodash'; +import type { + RouteMatcher, + HTTPMethod, + HTTPBody, + DefaultCreateSessionResult, + DriverData, + InitialOpts, + StringRecord, + ExternalDriver, + DriverOpts, + W3CDriverCaps, +} from '@appium/types'; import { BaseDriver } from 'appium/driver'; import { system } from 'appium/support'; import { WinAppDriver } from './winappdriver'; +import type { WindowsDriverCaps } from './winappdriver'; import { desiredCapConstraints } from './desired-caps'; import * as appManagementCommands from './commands/app-management'; import * as clipboardCommands from './commands/clipboard'; @@ -19,8 +32,7 @@ import { POWER_SHELL_FEATURE } from './constants'; import { newMethodMap } from './method-map'; import { executeMethodMap } from './execute-method-map'; -/** @type {import('@appium/types').RouteMatcher[]} */ -const NO_PROXY = [ +const NO_PROXY: RouteMatcher[] = [ ['GET', new RegExp('^/session/[^/]+/appium/(?!app/)[^/]+')], ['POST', new RegExp('^/session/[^/]+/appium/(?!app/)[^/]+')], ['POST', new RegExp('^/session/[^/]+/element/[^/]+/elements?$')], @@ -48,28 +60,20 @@ const NO_PROXY = [ ]; // Appium instantiates this class -/** - * @implements {ExternalDriver} - * @extends {BaseDriver} - */ -export class WindowsDriver extends BaseDriver { - /** @type {boolean} */ - isProxyActive; - - /** @type {import('@appium/types').RouteMatcher[]} */ - jwpProxyAvoid; - - /** @type {WinAppDriver} */ - winAppDriver; - - /** @type {import('./commands/record-screen').ScreenRecorder | null} */ - _screenRecorder; +export class WindowsDriver + extends BaseDriver + implements ExternalDriver +{ + private isProxyActive: boolean; + private jwpProxyAvoid: RouteMatcher[]; + private _winAppDriver: WinAppDriver | null; + _screenRecorder: recordScreenCommands.ScreenRecorder | null; + public proxyReqRes: (...args: any) => any; static newMethodMap = newMethodMap; static executeMethodMap = executeMethodMap; - constructor (opts = {}, shouldValidateCaps = true) { - // @ts-ignore TODO: Make opts typed + constructor(opts: InitialOpts, shouldValidateCaps = true) { super(opts, shouldValidateCaps); this.desiredCapConstraints = desiredCapConstraints; this.locatorStrategies = [ @@ -83,74 +87,67 @@ export class WindowsDriver extends BaseDriver { this.resetState(); } - resetState () { - this.jwpProxyAvoid = NO_PROXY; - this.isProxyActive = false; - // @ts-ignore It's ok - this.winAppDriver = null; - this._screenRecorder = null; + get winAppDriver(): WinAppDriver { + if (!this._winAppDriver) { + throw new Error('WinAppDriver is not started'); + } + return this._winAppDriver; } - // @ts-ignore TODO: Make args typed - async createSession (...args) { + override async createSession( + w3cCaps1: W3CWindowsDriverCaps, + w3cCaps2?: W3CWindowsDriverCaps, + w3cCaps3?: W3CWindowsDriverCaps, + driverData?: DriverData[] + ): Promise> { if (!system.isWindows()) { throw new Error('WinAppDriver tests only run on Windows'); } try { - // @ts-ignore TODO: Make args typed - const [sessionId, caps] = await super.createSession(...args); + const [sessionId, caps] = await super.createSession(w3cCaps1, w3cCaps2, w3cCaps3, driverData); + this.caps = caps; + this.opts = this.opts as WindowsDriverOpts; if (caps.prerun) { this.log.info('Executing prerun PowerShell script'); - if (!_.isString(caps.prerun.command) && !_.isString(caps.prerun.script)) { + const prerun = caps.prerun as PrerunCapability; + if (!_.isString(prerun.command) && !_.isString(prerun.script)) { throw new Error(`'prerun' capability value must either contain ` + `'script' or 'command' entry of string type`); } this.assertFeatureEnabled(POWER_SHELL_FEATURE); - const output = await this.execPowerShell(caps.prerun); + const output = await this.execPowerShell(prerun); if (output) { this.log.info(`Prerun script output: ${output}`); } } await this.startWinAppDriverSession(); return [sessionId, caps]; - } catch (e) { + } catch (e: any) { await this.deleteSession(); throw e; } } - async startWinAppDriverSession () { - this.winAppDriver = new WinAppDriver(this.log, { - url: this.opts.wadUrl, - port: this.opts.systemPort, - reqBasePath: this.basePath, - }); - await this.winAppDriver.start(this.caps); - this.proxyReqRes = this.winAppDriver.proxy?.proxyReqRes.bind(this.winAppDriver.proxy); - // now that everything has started successfully, turn on proxying so all - // subsequent session requests go straight to/from WinAppDriver - this.isProxyActive = true; - } - - async deleteSession () { + override async deleteSession(): Promise { this.log.debug('Deleting WinAppDriver session'); await this._screenRecorder?.stop(true); - await this.winAppDriver?.stop(); + await this._winAppDriver?.stop(); - if (this.opts.postrun) { - if (!_.isString(this.opts.postrun.command) && !_.isString(this.opts.postrun.script)) { + const postrun = this.opts.postrun as PostrunCapability | undefined; + if (postrun) { + if (!_.isString(postrun.command) && !_.isString(postrun.script)) { this.log.error(`'postrun' capability value must either contain ` + `'script' or 'command' entry of string type`); } else { this.log.info('Executing postrun PowerShell script'); try { this.assertFeatureEnabled(POWER_SHELL_FEATURE); - const output = await this.execPowerShell(this.opts.postrun); + const output = await this.execPowerShell(postrun); if (output) { this.log.info(`Postrun script output: ${output}`); } - } catch (e) { + } catch (e: any) { this.log.error(e.message); } } @@ -162,25 +159,45 @@ export class WindowsDriver extends BaseDriver { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - proxyActive (sessionId) { + override proxyActive(sessionId: string): boolean { return this.isProxyActive; } - canProxy () { + override canProxy(): boolean { // we can always proxy to the WinAppDriver server return true; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - getProxyAvoidList (sessionId) { + override getProxyAvoidList(sessionId: string): RouteMatcher[] { return this.jwpProxyAvoid; } - async proxyCommand (url, method, body) { + async proxyCommand(url: string, method: HTTPMethod, body: HTTPBody = null): Promise { if (!this.winAppDriver?.proxy) { throw new Error('The proxy must be defined in order to send commands'); } - return /** @type {any} */ (await this.winAppDriver.proxy.command(url, method, body)); + return await this.winAppDriver.proxy.command(url, method, body); + } + + async startWinAppDriverSession(): Promise { + this._winAppDriver = new WinAppDriver(this.log, { + url: this.opts.wadUrl, + port: this.opts.systemPort, + reqBasePath: this.basePath, + }); + await this.winAppDriver.start(this.caps as any as WindowsDriverCaps); + this.proxyReqRes = this.winAppDriver.proxy?.proxyReqRes.bind(this.winAppDriver.proxy); + // now that everything has started successfully, turn on proxying so all + // subsequent session requests go straight to/from WinAppDriver + this.isProxyActive = true; + } + + private resetState(): void { + this.jwpProxyAvoid = NO_PROXY; + this.isProxyActive = false; + this._winAppDriver = null; + this._screenRecorder = null; } windowsLaunchApp = appManagementCommands.windowsLaunchApp; @@ -197,7 +214,6 @@ export class WindowsDriver extends BaseDriver { windowsDeleteFile = fileCommands.windowsDeleteFile; windowsDeleteFolder = fileCommands.windowsDeleteFolder; - // @ts-ignore This is expected findElOrEls = findCommands.findElOrEls; getWindowSize = generalCommands.getWindowSize; @@ -230,13 +246,16 @@ export class WindowsDriver extends BaseDriver { export default WindowsDriver; -/** - * @typedef {typeof desiredCapConstraints} WindowsDriverConstraints - * @typedef {import('@appium/types').DriverOpts} WindowsDriverOpts - */ +interface PrerunCapability { + command?: string; + script?: string; +} + +interface PostrunCapability { + command?: string; + script?: string; +} -/** - * @template {import('@appium/types').Constraints} C - * @template [Ctx=string] - * @typedef {import('@appium/types').ExternalDriver} ExternalDriver - */ +type WindowsDriverConstraints = typeof desiredCapConstraints; +type WindowsDriverOpts = DriverOpts; +type W3CWindowsDriverCaps = W3CDriverCaps; diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts index 5e7fca17..43adeb7f 100644 --- a/lib/execute-method-map.ts +++ b/lib/execute-method-map.ts @@ -1,7 +1,9 @@ -import { ExecuteMethodMap } from '@appium/types'; +import type { ExecuteMethodMap } from '@appium/types'; +import type { WindowsDriver } from './driver'; export const executeMethodMap = { 'windows: startRecordingScreen': { + // @ts-ignore Type checked is confused command: 'windowsStartRecordingScreen', params: { optional: [ @@ -142,4 +144,4 @@ export const executeMethodMap = { ], }, }, -} as const satisfies ExecuteMethodMap; +} as const satisfies ExecuteMethodMap; diff --git a/lib/installer.js b/lib/installer.ts similarity index 78% rename from lib/installer.js rename to lib/installer.ts index 4ae66d09..9a5fb3dc 100644 --- a/lib/installer.js +++ b/lib/installer.ts @@ -3,7 +3,7 @@ import { fs, tempDir } from 'appium/support'; import path from 'path'; import { exec } from 'teen_process'; import { log } from './logger'; -import { queryRegistry } from './registry'; +import { queryRegistry, type RegEntry } from './registry'; import { runElevated } from './utils'; const POSSIBLE_WAD_INSTALL_ROOTS = [ @@ -16,7 +16,7 @@ const UNINSTALL_REG_ROOT = 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\ const REG_ENTRY_VALUE = 'Windows Application Driver'; const REG_ENTRY_KEY = 'DisplayName'; const REG_ENTRY_TYPE = 'REG_SZ'; -const INST_LOCATION_SCRIPT_BY_GUID = (guid) => ` +const INST_LOCATION_SCRIPT_BY_GUID = (guid: string): string => ` Set installer = CreateObject("WindowsInstaller.Installer") Set session = installer.OpenProduct("${guid}") session.DoAction("CostInitialize") @@ -25,11 +25,12 @@ WScript.Echo session.Property("INSTALLFOLDER") `.replace(/\n/g, '\r\n'); /** + * Fetches the MSI installation location for a given installer GUID * - * @param {string} installerGuid - * @returns {Promise} install location + * @param installerGuid - The MSI installer GUID + * @returns The installation location path */ -async function fetchMsiInstallLocation (installerGuid) { +async function fetchMsiInstallLocation(installerGuid: string): Promise { const tmpRoot = await tempDir.openDir(); const scriptPath = path.join(tmpRoot, 'get_wad_inst_location.vbs'); try { @@ -43,7 +44,7 @@ async function fetchMsiInstallLocation (installerGuid) { class WADNotFoundError extends Error {} -export const getWADExecutablePath = _.memoize(async function getWADInstallPath () { +export const getWADExecutablePath = _.memoize(async function getWADInstallPath(): Promise { const wadPath = process.env.APPIUM_WAD_PATH ?? ''; if (await fs.exists(wadPath)) { log.debug(`Loaded WinAppDriver path from the APPIUM_WAD_PATH environment variable: ${wadPath}`); @@ -53,9 +54,8 @@ export const getWADExecutablePath = _.memoize(async function getWADInstallPath ( // TODO: WAD installer should write the full path to it into the system registry const pathCandidates = POSSIBLE_WAD_INSTALL_ROOTS // remove unset env variables - .filter(Boolean) + .filter((root): root is string => Boolean(root)) // construct full path - // @ts-ignore The above filter does the job .map((root) => path.resolve(root, REG_ENTRY_VALUE, WAD_EXE_NAME)); for (const result of pathCandidates) { if (await fs.exists(result)) { @@ -66,12 +66,12 @@ export const getWADExecutablePath = _.memoize(async function getWADInstallPath ( log.debug('Checking the system registry for the corresponding MSI entry'); try { const uninstallEntries = await queryRegistry(UNINSTALL_REG_ROOT); - const wadEntry = uninstallEntries.find(({key, type, value}) => - key === REG_ENTRY_KEY && value === REG_ENTRY_VALUE && type === REG_ENTRY_TYPE + const wadEntry = uninstallEntries.find((entry: RegEntry) => + entry.key === REG_ENTRY_KEY && entry.value === REG_ENTRY_VALUE && entry.type === REG_ENTRY_TYPE ); if (wadEntry) { log.debug(`Found MSI entry: ${JSON.stringify(wadEntry)}`); - const installerGuid = /** @type {string} */ (_.last(wadEntry.root.split('\\'))); + const installerGuid = _.last(wadEntry.root.split('\\')) as string; // WAD MSI installer leaves InstallLocation registry value empty, // so we need to be hacky here const result = path.join( @@ -86,7 +86,7 @@ export const getWADExecutablePath = _.memoize(async function getWADInstallPath ( } else { log.debug('No WAD MSI entries have been found'); } - } catch (e) { + } catch (e: any) { if (e.stderr) { log.debug(e.stderr); } @@ -102,14 +102,15 @@ export const getWADExecutablePath = _.memoize(async function getWADInstallPath ( }); /** + * Checks if the current process is running with administrator privileges * - * @returns {Promise} + * @returns Promise that resolves to true if running as admin, false otherwise */ -export async function isAdmin () { +export async function isAdmin(): Promise { try { await exec('fsutil.exe', ['dirty', 'query', process.env.SystemDrive || 'C:']); return true; } catch { return false; } -}; +} diff --git a/lib/logger.js b/lib/logger.ts similarity index 100% rename from lib/logger.js rename to lib/logger.ts diff --git a/lib/method-map.js b/lib/method-map.ts similarity index 98% rename from lib/method-map.js rename to lib/method-map.ts index 28640370..fbf9008a 100644 --- a/lib/method-map.js +++ b/lib/method-map.ts @@ -1,6 +1,6 @@ // https://github.com/microsoft/WinAppDriver/blob/master/Docs/SupportedAPIs.md -export const newMethodMap = /** @type {const} */ ({ +export const newMethodMap = { '/session/:sessionId/appium/start_recording_screen': { POST: { command: 'startRecordingScreen', @@ -114,4 +114,4 @@ export const newMethodMap = /** @type {const} */ ({ '/session/:sessionId/element/:elementId/equals/:otherId': { GET: {command: 'equalsElement'}, }, -}); +} as const; diff --git a/lib/registry.js b/lib/registry.js deleted file mode 100644 index 7151c3c1..00000000 --- a/lib/registry.js +++ /dev/null @@ -1,72 +0,0 @@ -import _ from 'lodash'; -import { runElevated } from './utils'; - -const REG = 'reg.exe'; -const ENTRY_PATTERN = /^\s+(\w+)\s+([A-Z_]+)\s*(.*)/; - -function parseRegEntries (root, block) { - return (_.isEmpty(block) || _.isEmpty(root)) - ? [] - : block.reduce((acc, line) => { - const match = ENTRY_PATTERN.exec(line); - if (match) { - acc.push({root, key: match[1], type: match[2], value: match[3] || ''}); - } - return acc; - }, []); -} - -function parseRegQueryOutput (output) { - const result = []; - let root; - let regEntriesBlock = []; - for (const line of output.split('\n').map(_.trimEnd)) { - if (!line) { - continue; - } - - const curIndent = line.length - _.trimStart(line).length; - if (curIndent === 0) { - result.push(...parseRegEntries(root, regEntriesBlock)); - root = line; - regEntriesBlock = []; - } else { - regEntriesBlock.push(line); - } - } - result.push(...parseRegEntries(root, regEntriesBlock)); - return result; -} - -/** - * @typedef {Object} RegEntry - * @property {string} root Full path to the registry branch, for example - * HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\DirectDrawEx - * @property {string} key The registry key name - * @property {string} type One of possible registry value types, for example - * REG_DWORD or REG_SZ - * @property {string} value The actual value. Could be empty - */ - -/** - * Lists registry tree (e.g. recursively) under the given root node. - * The lookup is done under the same registry branch that the current process - * system architecture. - * - * @param {string} root The registry key name, which consists of two parts: - * - The root key: HKLM | HKCU | HKCR | HKU | HKCC - * - The subkey under the selected root key, for example \Software\Microsoft - * @returns {Promise} List of matched RegEntry instances or an empty list - * if either no entries were found under the given root or the root does not exist. - */ -async function queryRegistry (root) { - let stdout; - try { - ({stdout} = await runElevated(REG, ['query', root, '/s'])); - } catch { - return []; - } - return parseRegQueryOutput(stdout); -} - -export { queryRegistry, parseRegQueryOutput }; \ No newline at end of file diff --git a/lib/registry.ts b/lib/registry.ts new file mode 100644 index 00000000..409141a4 --- /dev/null +++ b/lib/registry.ts @@ -0,0 +1,80 @@ +import _ from 'lodash'; +import { runElevated } from './utils'; + +const REG = 'reg.exe'; +const ENTRY_PATTERN = /^\s+(\w+)\s+([A-Z_]+)\s*(.*)/; + +/** + * Parses the output of the reg query command into a list of RegEntry instances + * + * @param output - The output of the reg query command + * @returns List of matched RegEntry instances + */ +export function parseRegQueryOutput(output: string): RegEntry[] { + const result: RegEntry[] = []; + let root: string | undefined; + let regEntriesBlock: string[] = []; + const lines = output.split('\n').map((l: string) => _.trimEnd(l)); + for (const line of lines) { + if (!line) { + continue; + } + + const curIndent = line.length - _.trimStart(line).length; + if (curIndent === 0) { + result.push(...parseRegEntries(root, regEntriesBlock)); + root = line; + regEntriesBlock = []; + } else { + regEntriesBlock.push(line); + } + } + result.push(...parseRegEntries(root, regEntriesBlock)); + return result; +} + +/** + * Lists registry tree (e.g. recursively) under the given root node. + * The lookup is done under the same registry branch that the current process + * system architecture. + * + * @param root - The registry key name, which consists of two parts: + * - The root key: HKLM | HKCU | HKCR | HKU | HKCC + * - The subkey under the selected root key, for example \Software\Microsoft + * @returns List of matched RegEntry instances or an empty list + * if either no entries were found under the given root or the root does not exist. + */ +export async function queryRegistry(root: string): Promise { + let stdout: string; + try { + ({stdout} = await runElevated(REG, ['query', root, '/s'])); + } catch { + return []; + } + return parseRegQueryOutput(stdout); +} + +function parseRegEntries(root: string | undefined, block: string[]): RegEntry[] { + if (_.isEmpty(block) || !root || _.isEmpty(root)) { + return []; + } + return block.reduce((acc: RegEntry[], line: string) => { + const match = ENTRY_PATTERN.exec(line); + if (match) { + acc.push({root, key: match[1], type: match[2], value: match[3] || ''}); + } + return acc; + }, []); +} + +export interface RegEntry { + /** Full path to the registry branch, for example + * HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\DirectDrawEx */ + root: string; + /** The registry key name */ + key: string; + /** One of possible registry value types, for example REG_DWORD or REG_SZ */ + type: string; + /** The actual value. Could be empty */ + value: string; +} diff --git a/lib/utils.js b/lib/utils.ts similarity index 55% rename from lib/utils.js rename to lib/utils.ts index 67606046..d0b7934a 100644 --- a/lib/utils.js +++ b/lib/utils.ts @@ -2,6 +2,7 @@ import _ from 'lodash'; import { net } from 'appium/support'; import { promisify } from 'node:util'; import { exec } from 'node:child_process'; +import type { ExecOptions } from 'node:child_process'; import B from 'bluebird'; import { log } from './logger'; @@ -10,21 +11,25 @@ const execAsync = promisify(exec); /** * This API triggers UAC when necessary * - * @param {string} cmd - * @param {string[]} args - * @param {import('node:child_process').ExecOptions & {timeoutMs?: number}} opts - * @returns {Promise<{stdout: string; stderr: string;}>} - * @throws {import('node:child_process').ExecException} + * @param cmd - Command to execute + * @param args - Command arguments + * @param opts - Execution options including timeout + * @returns Promise with stdout and stderr + * @throws ExecException * * Notes: * - If the UAC prompt is cancelled by the user, Start-Process returns a non-zero exit code. */ -export async function runElevated(cmd, args = [], opts = {}) { +export async function runElevated( + cmd: string, + args: string[] = [], + opts: RunElevatedOptions = {} +): Promise<{stdout: string; stderr: string}> { const { timeoutMs = 60 * 1000 * 5 } = opts; - const escapePSSingleQuoted = (/** @type {string} */ str) => `'${String(str).replace(/'/g, "''")}'`; + const escapePSSingleQuoted = (str: string): string => `'${String(str).replace(/'/g, "''")}'`; const psFilePath = escapePSSingleQuoted(cmd); const psArgList = _.isEmpty(args) ? "''" : args.map(escapePSSingleQuoted).join(','); // Build the PowerShell Start-Process command (safe quoting for inner tokens) @@ -33,10 +38,8 @@ export async function runElevated(cmd, args = [], opts = {}) { // We avoid additional interpolation here by using only single-quoted literals inside the PS command. const fullCmd = `powershell -NoProfile -Command "${psCommand}"`; log.debug(`Executing command: ${fullCmd}`); - const { stdout, stderr } = /** @type {any} */ ( - await B.resolve(execAsync(fullCmd, opts)) - .timeout(timeoutMs, `The command '${fullCmd}' timed out after ${timeoutMs}ms`) - ); + const { stdout, stderr } = await B.resolve(execAsync(fullCmd, opts)) + .timeout(timeoutMs, `The command '${fullCmd}' timed out after ${timeoutMs}ms`); return { stdout: _.isString(stdout) ? stdout : stdout.toString(), stderr: _.isString(stderr) ? stderr : stderr.toString(), @@ -44,11 +47,16 @@ export async function runElevated(cmd, args = [], opts = {}) { } /** + * Downloads a file from a URL to a local path * - * @param {string} srcUrl - * @param {string} dstPath - * @returns {Promise} + * @param srcUrl - Source URL to download from + * @param dstPath - Destination file path + * @returns Promise that resolves when download is complete */ -export async function downloadToFile(srcUrl, dstPath) { +export async function downloadToFile(srcUrl: string, dstPath: string): Promise { await net.downloadFile(srcUrl, dstPath); } + +export interface RunElevatedOptions extends ExecOptions { + timeoutMs?: number; +} diff --git a/lib/winappdriver.js b/lib/winappdriver.ts similarity index 73% rename from lib/winappdriver.js rename to lib/winappdriver.ts index 8d7889af..ea342b76 100644 --- a/lib/winappdriver.js +++ b/lib/winappdriver.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; -import os from 'os'; -import path from 'path'; +import os from 'node:os'; +import path from 'node:path'; +import type { AppiumLogger, ProxyOptions, HTTPMethod, HTTPBody } from '@appium/types'; import { JWProxy, errors } from 'appium/driver'; import { SubProcess } from 'teen_process'; import { getWADExecutablePath } from './installer'; @@ -8,11 +9,11 @@ import { waitForCondition } from 'asyncbox'; import { execSync } from 'child_process'; import { util } from 'appium/support'; import { findAPortNotInUse, checkPortStatus } from 'portscanner'; - +import { desiredCapConstraints } from './desired-caps'; const DEFAULT_BASE_PATH = '/wd/hub'; const DEFAULT_HOST = '127.0.0.1'; -const WAD_PORT_RANGE = [4724, 4824]; +const WAD_PORT_RANGE = [4724, 4824] as const; const STARTUP_TIMEOUT_MS = 10000; const DEFAULT_CREATE_SESSION_TIMEOUT_MS = 20000; // retry start session creation during the timeout in milliseconds // The guard is needed to avoid dynamic system port allocation conflicts for @@ -24,10 +25,9 @@ const PORT_ALLOCATION_GUARD = util.getLockFileGuard(path.resolve(os.tmpdir(), 'w const TROUBLESHOOTING_LINK = 'https://github.com/appium/appium-windows-driver?tab=readme-ov-file#troubleshooting'; class WADProxy extends JWProxy { - /** @type {boolean|undefined} */ - didProcessExit; + didProcessExit?: boolean; - async isListening() { + async isListening(): Promise { const url = this.getUrlForProxy('/status'); const parsedUrl = new URL(url); const defaultPort = parsedUrl.protocol === 'https:' ? 443 : 80; @@ -39,10 +39,7 @@ class WADProxy extends JWProxy { } } - /** - * @override - */ - async proxyCommand (url, method, body = null) { + override async proxyCommand(url: string, method: HTTPMethod, body: HTTPBody = null): Promise { if (this.didProcessExit) { throw new errors.InvalidContextError( `'${method} ${url}' cannot be proxied to WinAppDriver server because ` + @@ -53,12 +50,14 @@ class WADProxy extends JWProxy { } class WADProcess { - /** - * - * @param {import('@appium/types').AppiumLogger} log - * @param {WADProcessOptions} opts - */ - constructor (log, opts) { + private readonly log: AppiumLogger; + readonly base: string; + port?: number; + private readonly executablePath: string; + proc: SubProcess | null; + private readonly isForceQuitEnabled: boolean; + + constructor(log: AppiumLogger, opts: WADProcessOptions) { this.log = log; this.base = opts.base; this.port = opts.port; @@ -67,11 +66,11 @@ class WADProcess { this.isForceQuitEnabled = opts.isForceQuitEnabled; } - get isRunning () { + get isRunning(): boolean { return !!(this.proc?.isRunning); } - async start () { + async start(): Promise { if (this.isRunning) { return; } @@ -112,11 +111,11 @@ class WADProcess { await this.proc.start(0); } - async stop () { + async stop(): Promise { if (this.isRunning) { try { await this.proc?.stop(); - } catch (e) { + } catch (e: any) { this.log.warn(`WinAppDriver process with PID ${this.proc?.pid} cannot be stopped. ` + `Original error: ${e.message}`); } @@ -124,7 +123,7 @@ class WADProcess { } } -const RUNNING_PROCESS_IDS = []; +const RUNNING_PROCESS_IDS: (number | undefined)[] = []; process.once('exit', () => { if (_.isEmpty(RUNNING_PROCESS_IDS)) { return; @@ -137,23 +136,27 @@ process.once('exit', () => { }); export class WinAppDriver { - /** - * - * @param {import('@appium/types').AppiumLogger} log - * @param {WinAppDriverOptions} opts - */ - constructor (log, opts) { + private readonly log: AppiumLogger; + private readonly opts: WinAppDriverOptions; + private process: WADProcess | null; + private _proxy: WADProxy | null; + + constructor(log: AppiumLogger, opts: WinAppDriverOptions) { this.log = log; this.opts = opts; this.process = null; - this.proxy = null; + this._proxy = null; + } + + get proxy(): WADProxy { + if (!this._proxy) { + throw new Error('WinAppDriver proxy is not initialized'); + } + return this._proxy; } - /** - * @param {WindowsDriverCaps} caps - */ - async start (caps) { + async start(caps: WindowsDriverCaps): Promise { if (this.opts.url) { await this._prepareSessionWithCustomServer(this.opts.url); } else { @@ -164,11 +167,29 @@ export class WinAppDriver { await this._startSession(caps); } - /** - * @param {boolean} isForceQuitEnabled - * @returns {Promise} - */ - async _prepareSessionWithBuiltInServer(isForceQuitEnabled) { + async stop(): Promise { + if (!this.process?.isRunning) { + return; + } + + if (this.proxy?.sessionId) { + this.log.debug('Deleting WinAppDriver server session'); + try { + await this.proxy.command('', 'DELETE'); + } catch (err: any) { + this.log.warn(`Did not get confirmation WinAppDriver deleteSession worked; ` + + `Error was: ${err.message}`); + } + } + + await this.process.stop(); + } + + async sendCommand(url: string, method: HTTPMethod, body: HTTPBody = null): Promise { + return await this.proxy?.command(url, method, body); + } + + private async _prepareSessionWithBuiltInServer(isForceQuitEnabled: boolean): Promise { const executablePath = await getWADExecutablePath(); this.process = new WADProcess(this.log, { base: DEFAULT_BASE_PATH, @@ -178,8 +199,11 @@ export class WinAppDriver { }); await this.process.start(); - /** @type {import('@appium/types').ProxyOptions} */ - const proxyOpts = { + if (!this.process.port) { + throw new Error('WinAppDriver process port was not set after starting'); + } + + const proxyOpts: ProxyOptions = { log: this.log, base: this.process.base, server: DEFAULT_HOST, @@ -188,7 +212,7 @@ export class WinAppDriver { if (this.opts.reqBasePath) { proxyOpts.reqBasePath = this.opts.reqBasePath; } - this.proxy = new WADProxy(proxyOpts); + this._proxy = new WADProxy(proxyOpts); this.proxy.didProcessExit = false; this.process.proc?.on('exit', () => { if (this.proxy) { @@ -196,8 +220,7 @@ export class WinAppDriver { } }); - /** @type {Error | undefined} */ - let lastError; + let lastError: Error | undefined; try { await waitForCondition(async () => { try { @@ -205,7 +228,7 @@ export class WinAppDriver { await this.proxy.command('/status', 'GET'); return true; } - } catch (err) { + } catch (err: any) { if (this.proxy?.didProcessExit) { throw new Error(err.message); } @@ -216,7 +239,7 @@ export class WinAppDriver { waitMs: STARTUP_TIMEOUT_MS, intervalMs: 1000, }); - } catch (e) { + } catch (e: any) { if (!lastError || this.proxy.didProcessExit) { throw e; } @@ -237,43 +260,38 @@ export class WinAppDriver { throw new Error(errorMessage); } const pid = this.process.proc?.pid; - RUNNING_PROCESS_IDS.push(pid); - this.process.proc?.on('exit', () => void _.pull(RUNNING_PROCESS_IDS, pid)); + if (pid) { + RUNNING_PROCESS_IDS.push(pid); + this.process.proc?.on('exit', () => void _.pull(RUNNING_PROCESS_IDS, pid)); + } } - /** - * - * @param {string} url - * @returns {Promise} - */ - async _prepareSessionWithCustomServer (url) { + private async _prepareSessionWithCustomServer(url: string): Promise { this.log.info(`Using custom WinAppDriver server URL: ${url}`); - /** @type {URL} */ - let parsedUrl; + let parsedUrl: URL; try { parsedUrl = new URL(url); - } catch (e) { + } catch (e: any) { throw new Error( `Cannot parse the provided WinAppDriver URL '${url}'. Original error: ${e.message}` ); } - /** @type {import('@appium/types').ProxyOptions} */ - const proxyOpts = { + const proxyOpts: ProxyOptions = { log: this.log, base: _.trimEnd(parsedUrl.pathname, '/'), server: parsedUrl.hostname, port: parseInt(parsedUrl.port, 10), - scheme: _.trimEnd(parsedUrl.protocol, ':'), + scheme: _.trimEnd(parsedUrl.protocol, ':') as 'http' | 'https', }; if (this.opts.reqBasePath) { proxyOpts.reqBasePath = this.opts.reqBasePath; } - this.proxy = new WADProxy(proxyOpts); + this._proxy = new WADProxy(proxyOpts); try { await this.proxy.command('/status', 'GET'); - } catch (e) { + } catch (e: any) { let errorMessage = ( `WinAppDriver server is not listening at ${url}. ` + `Make sure it is running and the provided wadUrl is correct` @@ -290,24 +308,21 @@ export class WinAppDriver { } } - /** - * @param {WindowsDriverCaps} caps - */ - async _startSession (caps) { + private async _startSession(caps: WindowsDriverCaps): Promise { const { createSessionTimeout = DEFAULT_CREATE_SESSION_TIMEOUT_MS } = caps; this.log.debug(`Starting WinAppDriver session. Will timeout in '${createSessionTimeout}' ms.`); let retryIteration = 0; - let lastError; + let lastError: Error | undefined; - const condFn = async () => { - lastError = null; + const condFn = async (): Promise => { + lastError = undefined; retryIteration++; try { await this.proxy?.command('/session', 'POST', {desiredCapabilities: caps}); return true; - } catch (error) { + } catch (error: any) { lastError = error; this.log.warn(`Could not start WinAppDriver session error = '${error.message}', attempt = ${retryIteration}`); return false; @@ -319,7 +334,7 @@ export class WinAppDriver { waitMs: createSessionTimeout, intervalMs: 500 }); - } catch (timeoutError) { + } catch (timeoutError: any) { this.log.debug(`timeoutError was ${timeoutError.message}`); if (lastError) { throw (lastError); @@ -327,47 +342,27 @@ export class WinAppDriver { throw new Error(`Could not start WinAppDriver session within ${createSessionTimeout} ms.`); } } +} - async stop () { - if (!this.process?.isRunning) { - return; - } - - if (this.proxy?.sessionId) { - this.log.debug('Deleting WinAppDriver server session'); - try { - await this.proxy.command('', 'DELETE'); - } catch (err) { - this.log.warn(`Did not get confirmation WinAppDriver deleteSession worked; ` + - `Error was: ${err.message}`); - } - } +export interface WADProcessOptions { + base: string; + port?: number; + executablePath: string; + isForceQuitEnabled: boolean; +} - await this.process.stop(); - } +export interface WinAppDriverOptions { + port?: number; + reqBasePath?: string; + url?: string; +} - async sendCommand (url, method, body) { - return await this.proxy?.command(url, method, body); - } +export type WindowsDriverCaps = { + [K in keyof typeof desiredCapConstraints]?: any; +} & { + 'ms:forcequit'?: boolean; + createSessionTimeout?: number; + prerun?: {command?: string; script?: string}; + postrun?: {command?: string; script?: string}; } -export default WinAppDriver; - -/** - * @typedef {Object} WinAppDriverOptions - * @property {number} [port] - * @property {string} [reqBasePath] - * @property {string} [url] - */ - -/** - * @typedef {Object} WADProcessOptions - * @property {string} base - * @property {number} [port] - * @property {string} executablePath - * @property {boolean} isForceQuitEnabled - */ - -/** - * @typedef {import('@appium/types').DriverCaps} WindowsDriverCaps - */ diff --git a/test/unit/driver-specs.ts b/test/unit/driver-specs.ts index 0d588e5f..f95379ac 100644 --- a/test/unit/driver-specs.ts +++ b/test/unit/driver-specs.ts @@ -1,5 +1,3 @@ -// transpile:mocha - import { WindowsDriver } from '../../lib/driver'; import sinon from 'sinon'; import B from 'bluebird'; @@ -10,7 +8,7 @@ import * as chaiAsPromised from 'chai-as-promised'; chai.use(chaiAsPromised.default); -describe('driver.js', function () { +describe('driver', function () { let isWindowsStub: sinon.SinonStub; before(async function () { @@ -30,44 +28,35 @@ describe('driver.js', function () { describe('createSession', function () { it('should set sessionId', async function () { - const driver = new WindowsDriver({ app: 'myapp'}, false); + const driver = new WindowsDriver({ app: 'myapp'} as any, false); sinon.mock(driver).expects('startWinAppDriverSession') .once() .returns(B.resolve()); - await driver.createSession(null, null, { alwaysMatch: { 'appium:cap': 'foo' }}); + await driver.createSession( + { alwaysMatch: { platformName: 'Windows', 'appium:automationName': 'Windows', 'appium:app': 'myapp' }, firstMatch: [{}] } + ); expect(driver.sessionId).to.exist; - expect((driver.caps as any).cap).to.equal('foo'); + expect((driver.caps as any).app).to.equal('myapp'); }); describe('context simulation', function () { it('should support context commands', async function () { - const driver = new WindowsDriver({ app: 'myapp'}, false); + const driver = new WindowsDriver({} as any, false); expect(await driver.getCurrentContext()).to.equal('NATIVE_APP'); expect(await driver.getContexts()).to.eql(['NATIVE_APP']); await driver.setContext('NATIVE_APP'); }); it('should throw an error if invalid context', async function () { - const driver = new WindowsDriver({ app: 'myapp'}, false); + const driver = new WindowsDriver({} as any, false); await expect(driver.setContext('INVALID_CONTEXT')).to.be.rejected; }); }); - - // TODO: Implement or delete - //it('should set the default context', async function () { - // let driver = new SelendroidDriver({}, false); - // sinon.mock(driver).expects('checkAppPresent') - // .returns(Promise.resolve()); - // sinon.mock(driver).expects('startSelendroidSession') - // .returns(Promise.resolve()); - // await driver.createSession({}); - // driver.curContext.should.equal('NATIVE_APP'); - //}); }); describe('proxying', function () { let driver: WindowsDriver; before(function () { - driver = new WindowsDriver({}, false); + driver = new WindowsDriver({ address: '127.0.0.1', port: 4723 } as any, false); driver.sessionId = 'abc'; }); describe('#proxyActive', function () { @@ -89,7 +78,8 @@ describe('driver.js', function () { it('should return jwpProxyAvoid array', function () { const avoidList = (driver.getProxyAvoidList as any)('abc'); expect(avoidList).to.be.an.instanceof(Array); - expect(avoidList).to.eql(driver.jwpProxyAvoid); + // eslint-disable-next-line dot-notation + expect(avoidList).to.eql(driver['jwpProxyAvoid']); }); it('should throw an error if session id is wrong', function () { expect(() => { (driver.getProxyAvoidList as any)('aaa'); }).to.throw;