diff --git a/src/install/browserPaths.ts b/src/install/browserPaths.ts index c48073d643d2c..ed0411c1e8c5e 100644 --- a/src/install/browserPaths.ts +++ b/src/install/browserPaths.ts @@ -40,6 +40,20 @@ export const hostPlatform = ((): BrowserPlatform => { return platform as BrowserPlatform; })(); +export function linuxLddDirectories(browserPath: string, browser: BrowserDescriptor): string[] { + if (browser.name === 'chromium') + return [path.join(browserPath, 'chrome-linux')]; + if (browser.name === 'firefox') + return [path.join(browserPath, 'firefox')]; + if (browser.name === 'webkit') { + return [ + path.join(browserPath, 'linux', 'minibrowser-gtk'), + path.join(browserPath, 'linux', 'minibrowser-wpe'), + ]; + } + return []; +} + export function executablePath(browserPath: string, browser: BrowserDescriptor): string | undefined { let tokens: string[] | undefined; if (browser.name === 'chromium') { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 1c927fde55cea..b5bfe9d5e3485 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -33,6 +33,7 @@ import * as types from '../types'; import { TimeoutSettings } from '../timeoutSettings'; import { WebSocketServer } from './webSocketServer'; import { LoggerSink } from '../loggerSink'; +import { validateDependencies } from './validateDependencies'; type FirefoxPrefsOptions = { firefoxUserPrefs?: { [key: string]: string | number | boolean } }; type LaunchOptions = types.LaunchOptions & { logger?: LoggerSink }; @@ -62,11 +63,13 @@ export abstract class BrowserTypeBase implements BrowserType { private _name: string; private _executablePath: string | undefined; private _webSocketNotPipe: WebSocketNotPipe | null; + private _browserDescriptor: browserPaths.BrowserDescriptor; readonly _browserPath: string; constructor(packagePath: string, browser: browserPaths.BrowserDescriptor, webSocketOrPipe: WebSocketNotPipe | null) { this._name = browser.name; const browsersPath = browserPaths.browsersPath(packagePath); + this._browserDescriptor = browser; this._browserPath = browserPaths.browserDirectory(browsersPath, browser); this._executablePath = browserPaths.executablePath(this._browserPath, browser); this._webSocketNotPipe = webSocketOrPipe; @@ -186,6 +189,11 @@ export abstract class BrowserTypeBase implements BrowserType { if (!executable) throw new Error(`No executable path is specified. Pass "executablePath" option directly.`); + if (!executablePath) { + // We can only validate dependencies for bundled browsers. + await validateDependencies(this._browserPath, this._browserDescriptor); + } + // Note: it is important to define these variables before launchProcess, so that we don't get // "Cannot access 'browserServer' before initialization" if something went wrong. let transport: ConnectionTransport | undefined = undefined; diff --git a/src/server/validateDependencies.ts b/src/server/validateDependencies.ts new file mode 100644 index 0000000000000..4d1f6c3728f88 --- /dev/null +++ b/src/server/validateDependencies.ts @@ -0,0 +1,75 @@ +import * as fs from 'fs'; +import * as util from 'util'; +import * as path from 'path'; +import * as os from 'os'; +import {spawn} from 'child_process'; +import {linuxLddDirectories, BrowserDescriptor} from '../install/browserPaths.js'; + +const accessAsync = util.promisify(fs.access.bind(fs)); +const checkExecutable = (filePath: string) => accessAsync(filePath, fs.constants.X_OK).then(() => true).catch(e => false); +const statAsync = util.promisify(fs.stat.bind(fs)); +const readdirAsync = util.promisify(fs.readdir.bind(fs)); + +export async function validateDependencies(browserPath: string, browser: BrowserDescriptor) { + // We currently only support Linux. + if (os.platform() !== 'linux') + return; + const directoryPaths = linuxLddDirectories(browserPath, browser); + const lddPaths: string[] = []; + for (const directoryPath of directoryPaths) + lddPaths.push(...(await executablesOrSharedLibraries(directoryPath))); + const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependencies(lddPath))); + const missingDeps = new Set(); + for (const deps of allMissingDeps) { + for (const dep of deps) + missingDeps.add(dep); + } + if (!missingDeps.size) + return; + const deps = [...missingDeps].sort().map(dep => ' ' + dep).join('\n'); + throw new Error('Host system is missing dependencies to run browser:\n' + deps); +} + +async function executablesOrSharedLibraries(directoryPath: string): Promise { + const allPaths = (await readdirAsync(directoryPath)).map(file => path.resolve(directoryPath, file)); + const allStats = await Promise.all(allPaths.map(aPath => statAsync(aPath))); + const filePaths = allPaths.filter((aPath, index) => allStats[index].isFile()); + + const executablersOrLibraries = (await Promise.all(filePaths.map(async filePath => { + const basename = path.basename(filePath).toLowerCase(); + if (basename.endsWith('.so') || basename.includes('.so.')) + return filePath; + if (await checkExecutable(filePath)) + return filePath; + return false; + }))).filter(Boolean); + + return executablersOrLibraries as string[]; +} + +async function missingFileDependencies(filePath: string): Promise> { + const {stdout} = await lddAsync(filePath); + const missingDeps = stdout.split('\n').map(line => line.trim()).filter(line => line.endsWith('not found') && line.includes('=>')).map(line => line.split('=>')[0].trim()); + return missingDeps; +} + +function lddAsync(filePath: string): Promise<{stdout: string, stderr: string, code: number}> { + const dirname = path.dirname(filePath); + const ldd = spawn('ldd', [filePath], { + cwd: dirname, + env: { + ...process.env, + LD_LIBRARY_PATH: dirname, + }, + }); + + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + ldd.stdout.on('data', data => stdout += data); + ldd.stderr.on('data', data => stderr += data); + ldd.on('close', (code) => { + resolve({stdout, stderr, code}); + }); + }); +}