diff --git a/.gitignore b/.gitignore index d6d8ebf..ffc4b44 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,8 @@ build/ # Ignore package-lock.json in favour of npm-shrinkwrap.json for npm publishing package-lock.json* + +# Ignore downloaded chromedriver/edgedriver/ffmpeg binaries +chromedriver/ +edgedriver/ +ffmpeg/ diff --git a/README.md b/README.md index 6fc62e1..13c5549 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,13 @@ appWorkingDir | Optional working directory path for the application. prerun | An object containing either `script` or `command` key. The value of each key must be a valid PowerShell script or command to be executed prior to the WinAppDriver session startup. See [Power Shell commands execution](#power-shell-commands-execution) for more details. Example: `{script: 'Get-Process outlook -ErrorAction SilentlyContinue'}` postrun | An object containing either `script` or `command` key. The value of each key must be a valid PowerShell script or command to be executed after WinAppDriver session is stopped. See [Power Shell commands execution](#power-shell-commands-execution) for more details. isolatedScriptExecution | Whether PowerShell scripts are executed in an isolated session. Default is `false`. -enableWebView | Whether to enable WebView support. Set to true to allow switching into WebView contexts. Default is `false`. +webviewEnabled | Whether to enable WebView support. Set to true to allow switching into WebView contexts. Default is `false`. webviewDevtoolsPort | The local port number to use for devtools communication. By default the first free port from 10900..11000 range is selected. Set a custom port if running parallel tests or when app is "none"/"root"/appTopLevelWindow is specified. +chromedriverCdnUrl | Base URL used to download ChromeDriver binaries for automating Chromium-based WebViews in desktop applications. Defaults to `https://storage.googleapis.com/chrome-for-testing-public`. +edgedriverCdnUrl | Base URL used to download EdgeDriver binaries for automating Edge (WebView2)-based WebViews in desktop applications. Defaults to `https://msedgedriver.microsoft.com`. +edgedriverExecutablePath | Absolute file path to a locally provided Microsoft Edge WebDriver binary. When this is set, automatic download via edgedriverCdnUrl is disabled and the provided executable is used directly. The binary must be explicitly supplied by the user (e.g. downloaded manually or stored in CI artifacts). It is the user’s responsibility to ensure the driver version matches the installed Edge / WebView2 runtime version used by the target environment, otherwise automation may fail due to version incompatibility. +chromedriverExecutablePath | Absolute file path to a locally provided ChromeDriver binary. When this is set, automatic download via chromedriverCdnUrl is disabled and the provided executable is used directly. The binary must be explicitly supplied (manually downloaded or managed externally, such as in CI). The user must ensure version compatibility between ChromeDriver and the target Chromium / WebView version, as mismatches can break automation. +ffmpegExecutablePath | Absolute file path to a locally provided FFmpeg executable binary. When this is set, automatic download of FFmpeg is disabled and the provided executable is used directly. The binary must be supplied manually (e.g. downloaded and stored in CI artifacts or bundled externally). It is the user’s responsibility to ensure the FFmpeg build is compatible with the target Windows environment. If the path is invalid or the file does not exist, execution will fail with an error. Please note that more capabilities will be added as the development of this driver progresses. Since it is still in its early stages, some features may be missing or subject to change. If you need a specific capability or encounter any issues, please feel free to open an issue. diff --git a/lib/commands/app.ts b/lib/commands/app.ts index fd5db08..5f702c6 100644 --- a/lib/commands/app.ts +++ b/lib/commands/app.ts @@ -16,7 +16,7 @@ import { import { sleep } from '../util'; import { errors, W3C_ELEMENT_KEY } from '@appium/base-driver'; import { - getWindowAllHandlesForProcessIds, + // getWindowAllHandlesForProcessIds, keyDown, keyUp, trySetForegroundWindow, diff --git a/lib/commands/contexts.ts b/lib/commands/contexts.ts index b87c2a5..68d697a 100644 --- a/lib/commands/contexts.ts +++ b/lib/commands/contexts.ts @@ -1,10 +1,7 @@ import { Chromedriver, ChromedriverOpts } from 'appium-chromedriver'; import { fs, node, system, tempDir, zip } from '@appium/support'; -import http from 'node:http'; -import https from 'node:https'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; -import { sleep } from '../util'; +import { cdpRequest, downloadFile, sleep, MODULE_NAME } from '../util'; import { NovaWindowsDriver } from '../driver'; import { errors } from '@appium/base-driver'; @@ -12,8 +9,6 @@ const NATIVE_APP = 'NATIVE_APP'; const WEBVIEW = 'WEBVIEW'; const WEBVIEW_BASE = `${WEBVIEW}_`; -const MODULE_NAME = 'appium-novawindows-driver'; - export async function getCurrentContext(this: NovaWindowsDriver): Promise { return this.currentContext ??= NATIVE_APP; } @@ -79,6 +74,7 @@ export async function setContext(this: NovaWindowsDriver, name?: string | null): 'goog:chromeOptions': options, }; + this.currentContext = name; await this.chromedriver.start(caps); this.log.debug('Chromedriver started. Session ID:', cd.sessionId()); @@ -158,7 +154,7 @@ interface CDPListResponseEntry { type CDPListResponse = CDPListResponseEntry[]; export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewMs?: number): Promise { - if (!this.caps.enableWebView) { + if (!this.caps.webviewEnabled) { throw new errors.InvalidArgumentError('WebView support is not enabled. Please set the "enableWebView" capability to true and try again.'); } @@ -178,10 +174,10 @@ export async function getWebViewDetails(this: NovaWindowsDriver, waitForWebviewM const port = this.webviewDevtoolsPort ??= this.caps.webviewDevtoolsPort ?? null; - const webViewDetails: WebViewDetails = { - info: await cdpRequest.call(this, ({ host, port, endpoint: '/json/version', timeout: 10000 })), - pages: await cdpRequest.call(this, ({ host, port, endpoint: '/json/list', timeout: 10000 })), - }; + const info = await (cdpRequest.call(this, ({ host, port, endpoint: '/json/version', timeout: 10000 })) as Promise).catch(() => undefined); + const pages = await (cdpRequest.call(this, ({ host, port, endpoint: '/json/list', timeout: 10000 })) as Promise).catch(() => undefined); + + const webViewDetails: WebViewDetails = { info, pages }; return webViewDetails; } @@ -206,28 +202,57 @@ async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' await fs.mkdir(driverDir); } - let downloadUrl = ''; const fileName = browserType === 'Edge' ? 'msedgedriver.exe' : 'chromedriver.exe'; const finalPath = path.join(driverDir, browserVersion, fileName); if (await fs.exists(finalPath)) { return finalPath; - }; + } + + const executablePath = browserType === 'Edge' + ? this.caps.edgedriverExecutablePath + : this.caps.chromedriverExecutablePath; + + if (executablePath) { + const exists = await fs.exists(executablePath); + if (!exists) { + throw new errors.InvalidArgumentError(`Driver executable not found at path: ${executablePath}`); + } + + this.log.debug( + `Using local ${browserType} driver executable at ${executablePath}. ` + + `Automatic download is disabled and CDN URLs are ignored. ` + + `You must ensure this binary matches the WebView/Chromium version (${browserVersion}).` + ); + + return executablePath; + } const arch = await system.arch(); const zipFilename = `${driverType}${browserType === 'Edge' ? '_' : '-'}win${arch}.zip`; + const CHROME_BASE_URL = this.caps.chromedriverCdnUrl || 'https://storage.googleapis.com/chrome-for-testing-public'; + const EDGE_BASE_URL = this.caps.edgedriverCdnUrl || 'https://msedgedriver.microsoft.com'; + + let downloadUrl = ''; + if (browserType === 'Chrome') { - downloadUrl = `https://storage.googleapis.com/chrome-for-testing-public/${browserVersion}/win${arch}/${zipFilename}`; - } else if (browserType === 'Edge') { - downloadUrl = `https://msedgedriver.microsoft.com/${browserVersion}/${zipFilename}`; + const url = new URL(CHROME_BASE_URL); + url.pathname = path.posix.join(url.pathname, browserVersion, `win${arch}`, zipFilename); + downloadUrl = url.toString(); + } else { + const url = new URL(EDGE_BASE_URL); + url.pathname = path.posix.join(url.pathname, browserVersion, zipFilename); + downloadUrl = url.toString(); } this.log.debug(`Downloading ${browserType} driver version ${browserVersion}...`); const tmpRoot = await tempDir.openDir(); await downloadFile(downloadUrl, tmpRoot); + try { await zip.extractAllTo(path.join(tmpRoot, zipFilename), tmpRoot); + const driverPath = await fs.walkDir( tmpRoot, true, @@ -236,78 +261,12 @@ async function getDriverExecutable(this: NovaWindowsDriver, browserType: 'Edge' if (!driverPath) { throw new errors.UnknownError(`The archive was unzipped properly, but did not find any ${driverType} executable.`); } + this.log.debug(`Moving the extracted '${fileName}' to '${finalPath}'`); await fs.mv(driverPath, finalPath, { mkdirp: true }); } finally { await fs.rimraf(tmpRoot); } - return finalPath; -} - -async function cdpRequest(this: NovaWindowsDriver, { host, port, endpoint, timeout }): Promise { - if (this?.log) { - this.log.debug(`Sending request to ${host}:${port}${endpoint}`); - } - return new Promise((resolve, reject) => { - const options = { - hostname: host, - port, - path: endpoint, - method: 'GET', - agent: new http.Agent({ keepAlive: false }), - timeout, - }; - - const req = http.request(options, (res) => { - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch (err) { - reject(err); - } - }); - }); - - req.on('error', reject); - req.on('timeout', () => { - req.destroy(new Error('Request timed out')); - }); - - req.end(); - }); -} - -async function downloadFile(url: string, destPath: string, timeout = 30000): Promise { - const protocol = url.startsWith('https') ? https : http; - const fileName = path.basename(new URL(url).pathname); - - const fullFilePath = path.join(destPath, fileName); - - return new Promise((resolve, reject) => { - const req = protocol.get(url, async (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`Download failed: ${res.statusCode}`)); - } - - try { - const fileStream = fs.createWriteStream(fullFilePath); - await pipeline(res, fileStream); - resolve(); - } catch (err) { - await fs.unlink(fullFilePath).catch(() => { }); - reject(err); - } - }); - - req.on('error', reject); - req.setTimeout(timeout, () => { - req.destroy(); - reject(new Error(`Timeout downloading from ${url}`)); - }); - }); + return finalPath; } diff --git a/lib/commands/extension.ts b/lib/commands/extension.ts index 4794723..9280c2d 100644 --- a/lib/commands/extension.ts +++ b/lib/commands/extension.ts @@ -120,7 +120,7 @@ export async function execute(this: NovaWindowsDriver, script: string, args: any } if (script === 'mobile:getContexts') { - if (!this.caps.enableWebView) { + if (!this.caps.webviewEnabled) { throw new errors.InvalidArgumentError('WebView support is not enabled. To use this command, enable WebView support by setting the "enableWebView" capability to true.'); } const { waitForWebviewMs }: { waitForWebviewMs?: number } = args[0] || {}; @@ -720,7 +720,7 @@ export async function startRecordingScreen(this: NovaWindowsDriver, args?: { } } const videoPath = outputPath ?? join(tmpdir(), `novawindows-recording-${Date.now()}.${DEFAULT_EXT}`); - this._screenRecorder = new ScreenRecorder(videoPath, this.log, { + this._screenRecorder = new ScreenRecorder(videoPath, this, { fps: fps !== undefined ? parseInt(String(fps), 10) : undefined, timeLimit: timeLimit !== undefined ? parseInt(String(timeLimit), 10) : undefined, preset, diff --git a/lib/commands/powershell.ts b/lib/commands/powershell.ts index 4166a5a..43f6020 100644 --- a/lib/commands/powershell.ts +++ b/lib/commands/powershell.ts @@ -80,7 +80,7 @@ export async function startPowerShellSession(this: NovaWindowsDriver): Promise | Record; } -async function requireFfmpegPath(): Promise { - const bundled = getBundledFfmpegPath(); +async function requireFfmpegPath(driver: NovaWindowsDriver): Promise { + const bundled = await getBundledFfmpegPath(driver); if (bundled) { return bundled; } - const ffmpegBinary = `ffmpeg${system.isWindows() ? '.exe' : ''}`; + const ffmpegBinary = 'ffmpeg.exe'; try { return await fs.which(ffmpegBinary); } catch { @@ -56,24 +56,28 @@ export async function uploadRecordedMedia( if (!remotePath) { return (await util.toInMemoryBase64(localFile)).toString(); } + const { user, pass, method, headers, fileFieldName, formFields } = uploadOptions; + const options: Record = { method: method ?? 'PUT', headers, fileFieldName, formFields, }; + if (user && pass) { options.auth = { user, pass }; } + await net.uploadFile(localFile, remotePath, options as Parameters[2]); return ''; } export class ScreenRecorder { - private log: AppiumLogger; + private _driver: NovaWindowsDriver; private _videoPath: string; - private _process: SubProcess | null = null; + private _process: ChildProcessWithoutNullStreams | null = null; private _fps: number; private _audioInput?: string; private _captureCursor: boolean; @@ -82,8 +86,8 @@ export class ScreenRecorder { private _videoFilter?: string; private _timeLimit: number; - constructor(videoPath: string, log: AppiumLogger, opts: ScreenRecorderOptions = {}) { - this.log = log; + constructor(videoPath: string, driver: NovaWindowsDriver, opts: ScreenRecorderOptions = {}) { + this._driver = driver; this._videoPath = videoPath; this._fps = opts.fps && opts.fps > 0 ? opts.fps : DEFAULT_FPS; this._audioInput = opts.audioInput; @@ -108,68 +112,95 @@ export class ScreenRecorder { } isRunning(): boolean { - return !!this._process?.isRunning; + return !!this._process && !this._process.killed; } async _enforceTermination(): Promise { if (this._process && this.isRunning()) { - this.log.debug('Force-stopping the currently running video recording'); + this._driver.log.debug('Force-stopping the currently running video recording'); try { - await this._process.stop('SIGKILL'); + this._process.kill('SIGKILL'); } catch {} } + this._process = null; + const videoPath = await this.getVideoPath(); if (videoPath) { await fs.rimraf(videoPath); } + return ''; } async start(): Promise { - const ffmpegPath = await requireFfmpegPath(); + const ffmpegPath = await requireFfmpegPath(this._driver); const args: string[] = [ - '-loglevel', 'error', - '-t', String(this._timeLimit), - '-f', 'gdigrab', + '-loglevel', + 'error', + '-t', + String(this._timeLimit), + '-f', + 'gdigrab', ...(this._captureCursor ? ['-capture_cursor', '1'] : []), ...(this._captureClicks ? ['-capture_mouse_clicks', '1'] : []), - '-framerate', String(this._fps), - '-i', 'desktop', + '-framerate', + String(this._fps), + '-i', + 'desktop', ...(this._audioInput ? ['-f', 'dshow', '-i', `audio=${this._audioInput}`] : []), - '-vcodec', 'libx264', - '-preset', this._preset, - '-tune', 'zerolatency', - '-pix_fmt', 'yuv420p', - '-movflags', '+faststart', - '-fflags', 'nobuffer', - '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', + '-vcodec', + 'libx264', + '-preset', + this._preset, + '-tune', + 'zerolatency', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + '-fflags', + 'nobuffer', + '-vf', + 'pad=ceil(iw/2)*2:ceil(ih/2)*2', ...(this._videoFilter ? ['-filter:v', this._videoFilter] : []), this._videoPath, ]; - this._process = new SubProcess(ffmpegPath, args, { windowsHide: true }); - this.log.debug(`Starting ffmpeg: ${util.quote([ffmpegPath, ...args])}`); + this._process = spawn(ffmpegPath, args, { windowsHide: true }); + + this._driver.log.debug(`Starting ffmpeg: ${util.quote([ffmpegPath, ...args])}`); - this._process.on('output', (stdout: string, stderr: string) => { - const out = stdout || stderr; - if (out?.trim()) { - this.log.debug(`[ffmpeg] ${out}`); + this._process.stdout.on('data', (data: Buffer) => { + const out = data.toString(); + if (out.trim()) { + this._driver.log.debug(`[ffmpeg] ${out.trim()}`); + } + }); + + this._process.stderr.on('data', (data: Buffer) => { + const out = data.toString(); + if (out.trim()) { + this._driver.log.debug(`[ffmpeg] ${out.trim()}`); } }); this._process.once('exit', async (code: number, signal: string) => { this._process = null; + if (code === 0) { - this.log.debug('Screen recording exited without errors'); + this._driver.log.debug('Screen recording exited without errors'); } else { await this._enforceTermination(); - this.log.warn(`Screen recording exited with error code ${code}, signal ${signal}`); + this._driver.log.warn(`Screen recording exited with error code ${code}, signal ${signal}`); } }); - await this._process.start(0); + await new Promise((resolve, reject) => { + this._process?.once('error', reject); + setTimeout(resolve, 50); + }); try { await waitForCondition( @@ -192,7 +223,7 @@ export class ScreenRecorder { ); } - this.log.info( + this._driver.log.info( `The video recording has started. Will timeout in ${util.pluralize('second', this._timeLimit, true)}`, ); } @@ -203,27 +234,36 @@ export class ScreenRecorder { } if (!this.isRunning()) { - this.log.debug('Screen recording is not running. Returning the recent result'); + this._driver.log.debug('Screen recording is not running. Returning the recent result'); return await this.getVideoPath(); } return new Promise((resolve, reject) => { const timer = setTimeout(async () => { await this._enforceTermination(); - reject(new Error(`Screen recording has failed to exit after ${PROCESS_SHUTDOWN_TIMEOUT}ms`)); + reject( + new Error( + `Screen recording has failed to exit after ${PROCESS_SHUTDOWN_TIMEOUT}ms`, + ), + ); }, PROCESS_SHUTDOWN_TIMEOUT); this._process?.once('exit', async (code: number, signal: string) => { clearTimeout(timer); + if (code === 0) { resolve(await this.getVideoPath()); } else { - reject(new Error(`Screen recording exited with error code ${code}, signal ${signal}`)); + reject( + new Error( + `Screen recording exited with error code ${code}, signal ${signal}`, + ), + ); } }); - this._process?.proc?.stdin?.write('q'); - this._process?.proc?.stdin?.end(); + this._process?.stdin?.write('q'); + this._process?.stdin?.end(); }); } -} +} \ No newline at end of file diff --git a/lib/constraints.ts b/lib/constraints.ts index 7e2108b..8b493cb 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -42,12 +42,27 @@ export const UI_AUTOMATION_DRIVER_CONSTRAINTS = { 'ms:forcequit': { isBoolean: true, }, - 'enableWebView': { + webviewEnabled: { isBoolean: true, }, - 'webviewDevtoolsPort': { + webviewDevtoolsPort: { isNumber: true, }, + chromedriverCdnUrl: { + isString: true, + }, + edgedriverCdnUrl: { + isString: true, + }, + chromedriverExecutablePath: { + isString: true, + }, + edgedriverExecutablePath: { + isString: true, + }, + ffmpegExecutablePath: { + isString: true, + }, } as const satisfies Constraints; export default UI_AUTOMATION_DRIVER_CONSTRAINTS; diff --git a/lib/util.ts b/lib/util.ts index 02f8f7c..6c86296 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,19 +1,233 @@ import { errors } from '@appium/base-driver'; +import { fs, zip, node, tempDir } from 'appium/support'; +import { pipeline } from 'node:stream/promises'; +import { NovaWindowsDriver } from './driver'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; + +export const MODULE_NAME = 'appium-novawindows-driver'; /** * Resolves the path to the bundled ffmpeg binary from the ffmpeg-static package. * Used by startRecordingScreen; no system PATH fallback. */ -export function getBundledFfmpegPath(): string | null { +export async function getBundledFfmpegPath(driver: NovaWindowsDriver): Promise { + const ffmpegExecutablePath = driver.caps.ffmpegExecutablePath; + + if (ffmpegExecutablePath) { + const exists = await fs.exists(ffmpegExecutablePath); + if (!exists) { + throw new errors.InvalidArgumentError( + `ffmpeg executable not found at: ${ffmpegExecutablePath}`, + ); + } + + return ffmpegExecutablePath; + } + + const root = node.getModuleRootSync(MODULE_NAME, __filename); + if (!root) { + throw new errors.InvalidArgumentError( + `Cannot find the root folder of the ${MODULE_NAME} Node.js module`, + ); + } + + const exePath = path.join(root, 'ffmpeg', 'ffmpeg.exe'); + + if (await fs.exists(exePath)) { + return exePath; + } + + const tmpRoot = await tempDir.openDir(); + + driver.log.info(`ffmpeg: downloading into temp folder ${tmpRoot}`); + try { - const mod = require('ffmpeg-static') as string | { default?: string } | undefined; - const path = typeof mod === 'string' ? mod : mod?.default; - return typeof path === 'string' && path.length > 0 ? path : null; - } catch { - return null; + await fs.mkdir(tmpRoot, { recursive: true }); + + driver.log.info(`ffmpeg: fetching latest release info from GitHub`); + + const res = await fetch( + 'https://api.github.com/repos/GyanD/codexffmpeg/releases/latest', + { + headers: { + accept: 'application/vnd.github+json', + }, + }, + ); + + if (!res.ok) { + throw new errors.UnknownError( + `Failed to fetch ffmpeg release: ${res.status}`, + ); + } + + driver.log.info(`ffmpeg: parsing release metadata`); + + const release = (await res.json()) as { + assets: { name: string; browser_download_url: string }[]; + }; + + const asset = release.assets?.find((a) => + a.name.endsWith('full_build.zip'), + ); + + if (!asset?.browser_download_url) { + throw new errors.UnknownError( + 'No ffmpeg full_build.zip asset found in latest release', + ); + } + + driver.log.info(`ffmpeg: downloading ${asset.name}`); + + const zipPath = path.join(tmpRoot, 'ffmpeg.zip'); + + await downloadFile(asset.browser_download_url, tmpRoot); + + const downloadedZip = path.join( + tmpRoot, + path.basename(asset.browser_download_url), + ); + + driver.log.info(`ffmpeg: verifying downloaded archive`); + + if (!(await fs.exists(downloadedZip))) { + throw new errors.UnknownError( + `Downloaded ffmpeg zip not found at ${downloadedZip}`, + ); + } + + await fs.rename(downloadedZip, zipPath); + + driver.log.info(`ffmpeg: extracting archive`); + + await zip.extractAllTo(zipPath, tmpRoot); + + await fs.unlink(zipPath).catch(() => {}); + + driver.log.info(`ffmpeg: searching for ffmpeg.exe`); + + const found = await fs.walkDir( + tmpRoot, + true, + (itemPath: string, isDirectory: boolean) => + !isDirectory && + path.basename(itemPath).toLowerCase() === 'ffmpeg.exe', + ); + + if (!found) { + throw new errors.UnknownError('ffmpeg.exe not found after extraction'); + } + + driver.log.info(`ffmpeg: moving binary to ${exePath}`); + + await fs.mkdir(path.dirname(exePath), { recursive: true }); + await fs.mv(found, exePath, { mkdirp: true }); + + driver.log.info(`ffmpeg: ready at ${exePath}`); + + return exePath; + } finally { + driver.log.info(`ffmpeg: cleaning up temp directory`); + await fs.rimraf(tmpRoot); } } +export async function cdpRequest( + this: NovaWindowsDriver | undefined, + { host, port, endpoint, timeout }, +): Promise { + if (this?.log) { + this.log.debug(`Sending request to ${host}:${port}${endpoint}`); + } + + return new Promise((resolve, reject) => { + const options = { + hostname: host, + port, + path: endpoint, + method: 'GET', + agent: new http.Agent({ keepAlive: false }), + timeout, + }; + + const req = http.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + }); + + req.on('error', reject); + + req.on('timeout', () => { + req.destroy(new Error('Request timed out')); + }); + + req.end(); + }); +} + +export async function downloadFile( + url: string, + destPath: string, + timeout = 30000, +): Promise { + const protocol = url.startsWith('https') ? https : http; + const fileName = path.basename(new URL(url).pathname); + + const fullFilePath = path.join(destPath, fileName); + + const request = (currentUrl: string, redirectCount = 0): Promise => new Promise((resolve, reject) => { + const req = protocol.get(currentUrl, async (res) => { + const status = res.statusCode ?? 0; + + if ([301, 302, 307, 308].includes(status) && res.headers.location) { + if (redirectCount >= 10) { + return reject(new Error(`Too many redirects while downloading ${url}`)); + } + + const nextUrl = new URL(res.headers.location, currentUrl).toString(); + return resolve(request(nextUrl, redirectCount + 1)); + } + + if (status !== 200) { + return reject( + new Error(`Download failed from ${currentUrl}: ${status} ${res.statusMessage}`), + ); + } + + try { + const fileStream = fs.createWriteStream(fullFilePath); + await pipeline(res, fileStream); + resolve(); + } catch (err) { + await fs.unlink(fullFilePath).catch(() => {}); + reject(err); + } + }); + + req.on('error', reject); + + req.setTimeout(timeout, () => { + req.destroy(); + reject(new Error(`Timeout downloading from ${currentUrl} after ${timeout}ms`)); + }); + }); + + return request(url); +} + const SupportedEasingFunctions = Object.freeze([ 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', ]); @@ -63,4 +277,4 @@ export class DeferredStringTemplate { } return out.join(''); } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 605faa3..7a45b03 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@appium/base-driver": "^10.1.0", "appium-chromedriver": "^8.2.21", "bezier-easing": "^2.1.0", - "ffmpeg-static": "^5.2.0", "koffi": "^2.14.1", "xpath-analyzer": "^3.0.1" }, @@ -61,5 +60,9 @@ "typescript-eslint": "^8.46.1", "vitest": "^2.1.0", "webdriverio": "^9.0.0" + }, + "optionalDependencies": { + "ffmpeg-win32-ia32": "npm:ffmpeg-static@4.1.0", + "ffmpeg-win32-x64": "npm:ffmpeg-static@4.1.0" } }