From 5e81c5b7ee1c5fc3da08ece67bc9eb19b4863ff6 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Fri, 17 Feb 2023 07:11:50 +0100 Subject: [PATCH] chore: implement launch command (#9692) Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com> --- packages/browsers/src/CLI.ts | 66 ++++- packages/browsers/src/browsers/chrome.ts | 12 +- packages/browsers/src/launcher.ts | 226 ++++++++++++++++++ .../browsers/test/src/chrome-data.spec.ts | 10 +- packages/browsers/test/src/cli.spec.ts | 11 +- packages/browsers/test/src/launcher.spec.ts | 82 ++++++- 6 files changed, 390 insertions(+), 17 deletions(-) diff --git a/packages/browsers/src/CLI.ts b/packages/browsers/src/CLI.ts index 258a10b2..b39cfea3 100644 --- a/packages/browsers/src/CLI.ts +++ b/packages/browsers/src/CLI.ts @@ -20,8 +20,9 @@ import {hideBin} from 'yargs/helpers'; import {Browser, BrowserPlatform} from './browsers/types.js'; import {fetch} from './fetch.js'; +import {computeExecutablePath, launch} from './launcher.js'; -type Arguments = { +type InstallArgs = { browser: { name: Browser; revision: string; @@ -30,6 +31,16 @@ type Arguments = { platform?: BrowserPlatform; }; +type LaunchArgs = { + browser: { + name: Browser; + revision: string; + }; + path?: string; + platform?: BrowserPlatform; + detached: boolean; +}; + export class CLI { #cachePath; @@ -40,13 +51,13 @@ export class CLI { async run(argv: string[]): Promise { await yargs(hideBin(argv)) .command( - '$0 install ', - 'run files', + 'install ', + 'Download and install the specified browser', yargs => { yargs.positional('browser', { description: 'The browser version', type: 'string', - coerce: (opt): Arguments['browser'] => { + coerce: (opt): InstallArgs['browser'] => { return { name: this.#parseBrowser(opt), revision: this.#parseRevision(opt), @@ -55,7 +66,7 @@ export class CLI { }); }, async argv => { - const args = argv as unknown as Arguments; + const args = argv as unknown as InstallArgs; await fetch({ browser: args.browser.name, revision: args.browser.revision, @@ -79,6 +90,51 @@ export class CLI { choices: Object.values(BrowserPlatform), defaultDescription: 'Auto-detected by default.', }) + .command( + 'launch ', + 'Launch the specified browser', + yargs => { + yargs.positional('browser', { + description: 'The browser version', + type: 'string', + coerce: (opt): LaunchArgs['browser'] => { + return { + name: this.#parseBrowser(opt), + revision: this.#parseRevision(opt), + }; + }, + }); + }, + async argv => { + const args = argv as unknown as LaunchArgs; + const executablePath = computeExecutablePath({ + browser: args.browser.name, + revision: args.browser.revision, + cacheDir: args.path ?? this.#cachePath, + platform: args.platform, + }); + launch({ + executablePath, + detached: args.detached, + }); + } + ) + .option('path', { + type: 'string', + desc: 'Path where the browsers will be downloaded to and installed from', + default: process.cwd(), + }) + .option('detached', { + type: 'boolean', + desc: 'Whether to detach the child process.', + default: false, + }) + .option('platform', { + type: 'string', + desc: 'Platform that the binary needs to be compatible with.', + choices: Object.values(BrowserPlatform), + defaultDescription: 'Auto-detected by default.', + }) .parse(); } diff --git a/packages/browsers/src/browsers/chrome.ts b/packages/browsers/src/browsers/chrome.ts index b7e1a478..6a4e26a4 100644 --- a/packages/browsers/src/browsers/chrome.ts +++ b/packages/browsers/src/browsers/chrome.ts @@ -65,11 +65,17 @@ export function relativeExecutablePath( switch (platform) { case BrowserPlatform.MAC: case BrowserPlatform.MAC_ARM: - return path.join('Chromium.app', 'Contents', 'MacOS', 'Chromium'); + return path.join( + 'chrome-mac', + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium' + ); case BrowserPlatform.LINUX: - return 'chrome'; + return path.join('chrome-linux', 'chrome'); case BrowserPlatform.WIN32: case BrowserPlatform.WIN64: - return 'chrome.exe'; + return path.join('chrome-win', 'chrome.exe'); } } diff --git a/packages/browsers/src/launcher.ts b/packages/browsers/src/launcher.ts index 59fa964f..05cfa58e 100644 --- a/packages/browsers/src/launcher.ts +++ b/packages/browsers/src/launcher.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import childProcess from 'child_process'; import os from 'os'; import path from 'path'; @@ -23,8 +24,11 @@ import { executablePathByBrowser, } from './browsers/browsers.js'; import {CacheStructure} from './CacheStructure.js'; +import {debug} from './debug.js'; import {detectBrowserPlatform} from './detectPlatform.js'; +const debugLaunch = debug('puppeteer:browsers:launcher'); + /** * @public */ @@ -67,3 +71,225 @@ export function computeExecutablePath(options: Options): string { executablePathByBrowser[options.browser](options.platform, options.revision) ); } + +type LaunchOptions = { + executablePath: string; + pipe?: boolean; + dumpio?: boolean; + args?: string[]; + env?: Record; + handleSIGINT?: boolean; + handleSIGTERM?: boolean; + handleSIGHUP?: boolean; + detached?: boolean; +}; + +export function launch(opts: LaunchOptions): Process { + return new Process(opts); +} + +class Process { + #executablePath; + #args: string[]; + #browserProcess: childProcess.ChildProcess; + #exited = false; + #browserProcessExiting: Promise; + + constructor(opts: LaunchOptions) { + this.#executablePath = opts.executablePath; + this.#args = opts.args ?? []; + + opts.pipe ??= false; + opts.dumpio ??= false; + opts.handleSIGINT ??= true; + opts.handleSIGTERM ??= true; + opts.handleSIGHUP ??= true; + opts.detached ??= true; + + const stdio = this.#configureStdio({ + pipe: opts.pipe, + dumpio: opts.dumpio, + }); + + debugLaunch(`Launching ${this.#executablePath} ${this.#args.join(' ')}`); + + this.#browserProcess = childProcess.spawn( + this.#executablePath, + this.#args, + { + // On non-windows platforms, `detached: true` makes child process a + // leader of a new process group, making it possible to kill child + // process tree with `.kill(-pid)` command. @see + // https://nodejs.org/api/child_process.html#child_process_options_detached + detached: opts.detached, + env: opts.env, + stdio, + } + ); + if (opts.dumpio) { + this.#browserProcess.stderr?.pipe(process.stderr); + this.#browserProcess.stdout?.pipe(process.stdout); + } + process.on('exit', this.#onDriverProcessExit); + if (opts.handleSIGINT) { + process.on('SIGINT', this.#onDriverProcessSignal); + } + if (opts.handleSIGTERM) { + process.on('SIGTERM', this.#onDriverProcessSignal); + } + if (opts.handleSIGHUP) { + process.on('SIGHUP', this.#onDriverProcessSignal); + } + this.#browserProcessExiting = new Promise(resolve => { + this.#browserProcess.once('exit', () => { + this.#exited = true; + this.#clearListeners(); + resolve(); + }); + }); + } + + #configureStdio(opts: { + pipe: boolean; + dumpio: boolean; + }): Array<'ignore' | 'pipe'> { + if (opts.pipe) { + if (opts.dumpio) { + return ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + } else { + return ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + } else { + if (opts.dumpio) { + return ['pipe', 'pipe', 'pipe']; + } else { + return ['pipe', 'ignore', 'pipe']; + } + } + } + + #clearListeners(): void { + process.off('exit', this.#onDriverProcessExit); + process.off('SIGINT', this.#onDriverProcessSignal); + process.off('SIGTERM', this.#onDriverProcessSignal); + process.off('SIGHUP', this.#onDriverProcessSignal); + } + + #onDriverProcessExit = (_code: number) => { + this.kill(); + }; + + #onDriverProcessSignal = (signal: string): void => { + switch (signal) { + case 'SIGINT': + this.kill(); + process.exit(130); + case 'SIGTERM': + case 'SIGUP': + this.kill(); + break; + } + }; + + close(): Promise { + if (this.#exited) { + return Promise.resolve(); + } + this.kill(); + return this.#browserProcessExiting; + } + + kill(): void { + // If the process failed to launch (for example if the browser executable path + // is invalid), then the process does not get a pid assigned. A call to + // `proc.kill` would error, as the `pid` to-be-killed can not be found. + if ( + this.#browserProcess && + this.#browserProcess.pid && + pidExists(this.#browserProcess.pid) + ) { + try { + if (process.platform === 'win32') { + childProcess.exec( + `taskkill /pid ${this.#browserProcess.pid} /T /F`, + error => { + if (error) { + // taskkill can fail to kill the process e.g. due to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + this.#browserProcess.kill(); + } + } + ); + } else { + // on linux the process group can be killed with the group id prefixed with + // a minus sign. The process group id is the group leader's pid. + const processGroupId = -this.#browserProcess.pid; + + try { + process.kill(processGroupId, 'SIGKILL'); + } catch (error) { + // Killing the process group can fail due e.g. to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + this.#browserProcess.kill('SIGKILL'); + } + } + } catch (error) { + throw new Error( + `${PROCESS_ERROR_EXPLANATION}\nError cause: ${ + isErrorLike(error) ? error.stack : error + }` + ); + } + } + this.#clearListeners(); + } +} + +const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. +This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. +Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. +If you think this is a bug, please report it on the Puppeteer issue tracker.`; + +/** + * @internal + */ +function pidExists(pid: number): boolean { + try { + return process.kill(pid, 0); + } catch (error) { + if (isErrnoException(error)) { + if (error.code && error.code === 'ESRCH') { + return false; + } + } + throw error; + } +} + +/** + * @internal + */ +export interface ErrorLike extends Error { + name: string; + message: string; +} + +/** + * @internal + */ +export function isErrorLike(obj: unknown): obj is ErrorLike { + return ( + typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj + ); +} +/** + * @internal + */ +export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException { + return ( + isErrorLike(obj) && + ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj) + ); +} diff --git a/packages/browsers/test/src/chrome-data.spec.ts b/packages/browsers/test/src/chrome-data.spec.ts index 29a1626d..2f201ddc 100644 --- a/packages/browsers/test/src/chrome-data.spec.ts +++ b/packages/browsers/test/src/chrome-data.spec.ts @@ -50,23 +50,23 @@ describe('Chrome', () => { it('should resolve executable paths', () => { assert.strictEqual( relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), - path.join('chrome') + path.join('chrome-linux', 'chrome') ); assert.strictEqual( relativeExecutablePath(BrowserPlatform.MAC, '12372323'), - path.join('Chromium.app', 'Contents', 'MacOS', 'Chromium') + path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') ); assert.strictEqual( relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), - path.join('Chromium.app', 'Contents', 'MacOS', 'Chromium') + path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') ); assert.strictEqual( relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), - path.join('chrome.exe') + path.join('chrome-win', 'chrome.exe') ); assert.strictEqual( relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), - path.join('chrome.exe') + path.join('chrome-win', 'chrome.exe') ); }); }); diff --git a/packages/browsers/test/src/cli.spec.ts b/packages/browsers/test/src/cli.spec.ts index 89105fe3..aa26511a 100644 --- a/packages/browsers/test/src/cli.spec.ts +++ b/packages/browsers/test/src/cli.spec.ts @@ -46,7 +46,14 @@ describe('CLI', function () { '--platform=linux', ]); assert.ok( - fs.existsSync(path.join(tmpDir, 'chrome', `linux-${testChromeRevision}`)) + fs.existsSync( + path.join( + tmpDir, + 'chrome', + `linux-${testChromeRevision}`, + 'chrome-linux' + ) + ) ); }); @@ -61,7 +68,7 @@ describe('CLI', function () { ]); assert.ok( fs.existsSync( - path.join(tmpDir, 'firefox', `linux-${testFirefoxRevision}`) + path.join(tmpDir, 'firefox', `linux-${testFirefoxRevision}`, 'firefox') ) ); }); diff --git a/packages/browsers/test/src/launcher.spec.ts b/packages/browsers/test/src/launcher.spec.ts index 5dc2e4c4..e4f66d01 100644 --- a/packages/browsers/test/src/launcher.spec.ts +++ b/packages/browsers/test/src/launcher.spec.ts @@ -15,10 +15,13 @@ */ import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; import path from 'path'; import {Browser, BrowserPlatform} from '../../lib/cjs/browsers/browsers.js'; -import {computeExecutablePath} from '../../lib/cjs/launcher.js'; +import {fetch} from '../../lib/cjs/fetch.js'; +import {computeExecutablePath, launch} from '../../lib/cjs/launcher.js'; describe('launcher', () => { it('should compute executable path for Chrome', () => { @@ -29,9 +32,10 @@ describe('launcher', () => { revision: '123', cacheDir: 'cache', }), - path.join('cache', 'chrome', 'linux-123', 'chrome') + path.join('cache', 'chrome', 'linux-123', 'chrome-linux', 'chrome') ); }); + it('should compute executable path for Firefox', () => { assert.strictEqual( computeExecutablePath({ @@ -43,4 +47,78 @@ describe('launcher', () => { path.join('cache', 'firefox', 'linux-123', 'firefox', 'firefox') ); }); + + describe('Chrome', function () { + this.timeout(60000); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + const testChromeRevision = '1083080'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await fetch({ + cacheDir: tmpDir, + browser: Browser.CHROME, + revision: testChromeRevision, + }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true}); + }); + + it('should launch a Chrome browser', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROME, + revision: testChromeRevision, + }); + const process = launch({ + executablePath, + args: [ + '--use-mock-keychain', + '--disable-features=DialMediaRouteProvider', + `--user-data-dir=${path.join(tmpDir, 'profile')}`, + ], + }); + await process.close(); + }); + }); + + describe('Firefox', function () { + this.timeout(60000); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + const testFirefoxRevision = '111.0a1'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await fetch({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + revision: testFirefoxRevision, + }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true}); + }); + + it('should launch a Firefox browser', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + revision: testFirefoxRevision, + }); + const process = launch({ + executablePath, + args: [`--user-data-dir=${path.join(tmpDir, 'profile')}`], + }); + await process.close(); + }); + }); });