From 53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 23 Nov 2023 09:51:37 +0100 Subject: [PATCH] feat: implement the Puppeteer CLI (#11344) --- .../browsers.cli._constructor_.md | 26 +++++- docs/browsers-api/browsers.cli.md | 6 +- docs/contributing.md | 2 +- docs/faq.md | 6 ++ package-lock.json | 3 + packages/browsers/src/CLI.ts | 93 ++++++++++++++++--- .../src/node/ProductLauncher.ts | 4 +- packages/puppeteer/package.json | 3 +- packages/puppeteer/src/node/cli.ts | 41 ++++++++ test/installation/src/puppeteer-cli.spec.ts | 68 ++++++++++++++ 10 files changed, 226 insertions(+), 26 deletions(-) create mode 100644 packages/puppeteer/src/node/cli.ts create mode 100644 test/installation/src/puppeteer-cli.spec.ts diff --git a/docs/browsers-api/browsers.cli._constructor_.md b/docs/browsers-api/browsers.cli._constructor_.md index e671661707b..5818c840a01 100644 --- a/docs/browsers-api/browsers.cli._constructor_.md +++ b/docs/browsers-api/browsers.cli._constructor_.md @@ -10,13 +10,29 @@ Constructs a new instance of the `CLI` class ```typescript class CLI { - constructor(cachePath?: string, rl?: readline.Interface); + constructor( + opts?: + | string + | { + cachePath?: string; + scriptName?: string; + prefixCommand?: { + cmd: string; + description: string; + }; + allowCachePathOverride?: boolean; + pinnedBrowsers?: Partial<{ + [key in Browser]: string; + }>; + }, + rl?: readline.Interface + ); } ``` ## Parameters -| Parameter | Type | Description | -| --------- | ------------------ | ------------ | -| cachePath | string | _(Optional)_ | -| rl | readline.Interface | _(Optional)_ | +| Parameter | Type | Description | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| opts | string \| { cachePath?: string; scriptName?: string; prefixCommand?: { cmd: string; description: string; }; allowCachePathOverride?: boolean; pinnedBrowsers?: Partial<{ \[key in [Browser](./browsers.browser.md)\]: string; }>; } | _(Optional)_ | +| rl | readline.Interface | _(Optional)_ | diff --git a/docs/browsers-api/browsers.cli.md b/docs/browsers-api/browsers.cli.md index 1e4fc22c841..501fd242fc7 100644 --- a/docs/browsers-api/browsers.cli.md +++ b/docs/browsers-api/browsers.cli.md @@ -12,9 +12,9 @@ export declare class CLI ## Constructors -| Constructor | Modifiers | Description | -| --------------------------------------------------------------- | --------- | ------------------------------------------------------- | -| [(constructor)(cachePath, rl)](./browsers.cli._constructor_.md) | | Constructs a new instance of the CLI class | +| Constructor | Modifiers | Description | +| ---------------------------------------------------------- | --------- | ------------------------------------------------------- | +| [(constructor)(opts, rl)](./browsers.cli._constructor_.md) | | Constructs a new instance of the CLI class | ## Methods diff --git a/docs/contributing.md b/docs/contributing.md index 5285b37524a..fcd41c53600 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -32,7 +32,7 @@ again. ```bash npm install - # Or to download Firefox + # Or to download Firefox by default PUPPETEER_PRODUCT=firefox npm install ``` diff --git a/docs/faq.md b/docs/faq.md index a23efe9849e..6b64cad7453 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -117,6 +117,12 @@ To fetch Firefox Nightly as part of Puppeteer installation: PUPPETEER_PRODUCT=firefox npm i puppeteer ``` +To download Firefox Nightly into an existing Puppeteer project: + +```bash +npx puppeteer browsers install firefox +``` + #### Q: What’s considered a “Navigation”? From Puppeteer’s standpoint, **“navigation” is anything that changes a page’s diff --git a/package-lock.json b/package-lock.json index 160d67c9125..2f82c07097f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11986,6 +11986,9 @@ "cosmiconfig": "8.3.6", "puppeteer-core": "21.5.2" }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, "devDependencies": { "@types/node": "18.17.15" }, diff --git a/packages/browsers/src/CLI.ts b/packages/browsers/src/CLI.ts index a809adda423..5df3e08ada2 100644 --- a/packages/browsers/src/CLI.ts +++ b/packages/browsers/src/CLI.ts @@ -68,10 +68,37 @@ interface ClearArgs { export class CLI { #cachePath; #rl?: readline.Interface; + #scriptName = ''; + #allowCachePathOverride = true; + #pinnedBrowsers?: Partial<{[key in Browser]: string}>; + #prefixCommand?: {cmd: string; description: string}; - constructor(cachePath = process.cwd(), rl?: readline.Interface) { - this.#cachePath = cachePath; + constructor( + opts?: + | string + | { + cachePath?: string; + scriptName?: string; + prefixCommand?: {cmd: string; description: string}; + allowCachePathOverride?: boolean; + pinnedBrowsers?: Partial<{[key in Browser]: string}>; + }, + rl?: readline.Interface + ) { + if (!opts) { + opts = {}; + } + if (typeof opts === 'string') { + opts = { + cachePath: opts, + }; + } + this.#cachePath = opts.cachePath ?? process.cwd(); this.#rl = rl; + this.#scriptName = opts.scriptName ?? '@puppeteer/browsers'; + this.#allowCachePathOverride = opts.allowCachePathOverride ?? true; + this.#pinnedBrowsers = opts.pinnedBrowsers; + this.#prefixCommand = opts.prefixCommand; } #defineBrowserParameter(yargs: Yargs.Argv): void { @@ -98,6 +125,9 @@ export class CLI { } #definePathParameter(yargs: Yargs.Argv, required = false): void { + if (!this.#allowCachePathOverride) { + return; + } yargs.option('path', { type: 'string', desc: 'Path to the root folder for the browser downloads and installation. The installation folder structure is compatible with the cache structure used by Puppeteer.', @@ -111,8 +141,28 @@ export class CLI { async run(argv: string[]): Promise { const yargsInstance = yargs(hideBin(argv)); - await yargsInstance - .scriptName('@puppeteer/browsers') + let target = yargsInstance.scriptName(this.#scriptName); + if (this.#prefixCommand) { + target = target.command( + this.#prefixCommand.cmd, + this.#prefixCommand.description, + yargs => { + return this.#build(yargs); + } + ); + } else { + target = this.#build(target); + } + await target + .demandCommand(1) + .help() + .wrap(Math.min(120, yargsInstance.terminalWidth())) + .parse(); + } + + #build(yargs: Yargs.Argv): Yargs.Argv { + const latestOrPinned = this.#pinnedBrowsers ? 'pinned' : 'latest'; + return yargs .command( 'install ', 'Download and install the specified browser. If successful, the command outputs the actual browser buildId that was installed and the absolute path to the browser executable (format: @ ).', @@ -126,7 +176,7 @@ export class CLI { }); yargs.example( '$0 install chrome', - 'Install the latest available build of the Chrome browser.' + `Install the ${latestOrPinned} available build of the Chrome browser.` ); yargs.example( '$0 install chrome@latest', @@ -176,10 +226,12 @@ export class CLI { '$0 install firefox --platform mac', 'Install the latest Mac (Intel) build of the Firefox browser.' ); - yargs.example( - '$0 install firefox --path /tmp/my-browser-cache', - 'Install to the specified cache directory.' - ); + if (this.#allowCachePathOverride) { + yargs.example( + '$0 install firefox --path /tmp/my-browser-cache', + 'Install to the specified cache directory.' + ); + } }, async argv => { const args = argv as unknown as InstallArgs; @@ -187,6 +239,15 @@ export class CLI { if (!args.platform) { throw new Error(`Could not resolve the current platform`); } + if (args.browser.buildId === 'pinned') { + const pinnedVersion = this.#pinnedBrowsers?.[args.browser.name]; + if (!pinnedVersion) { + throw new Error( + `No pinned version found for ${args.browser.name}` + ); + } + args.browser.buildId = pinnedVersion; + } args.browser.buildId = await resolveBuildId( args.browser.name, args.platform, @@ -272,7 +333,9 @@ export class CLI { ) .command( 'clear', - 'Removes all installed browsers from the specified cache directory', + this.#allowCachePathOverride + ? 'Removes all installed browsers from the specified cache directory' + : `Removes all installed browsers from ${this.#cachePath}`, yargs => { this.#definePathParameter(yargs, true); }, @@ -296,9 +359,7 @@ export class CLI { } ) .demandCommand(1) - .help() - .wrap(Math.min(120, yargsInstance.terminalWidth())) - .parse(); + .help(); } #parseBrowser(version: string): Browser { @@ -307,7 +368,11 @@ export class CLI { #parseBuildId(version: string): string { const parts = version.split('@'); - return parts.length === 2 ? parts[1]! : 'latest'; + return parts.length === 2 + ? parts[1]! + : this.#pinnedBrowsers + ? 'pinned' + : 'latest'; } } diff --git a/packages/puppeteer-core/src/node/ProductLauncher.ts b/packages/puppeteer-core/src/node/ProductLauncher.ts index ed3b8f50759..355e1c69469 100644 --- a/packages/puppeteer-core/src/node/ProductLauncher.ts +++ b/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -423,14 +423,14 @@ export abstract class ProductLauncher { case 'chrome': throw new Error( `Could not find Chrome (ver. ${this.puppeteer.browserRevision}). This can occur if either\n` + - ' 1. you did not perform an installation before running the script (e.g. `npm install`) or\n' + + ' 1. you did not perform an installation before running the script (e.g. `npx puppeteer browsers install chrome`) or\n' + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' ); case 'firefox': throw new Error( `Could not find Firefox (rev. ${this.puppeteer.browserRevision}). This can occur if either\n` + - ' 1. you did not perform an installation for Firefox before running the script (e.g. `PUPPETEER_PRODUCT=firefox npm install`) or\n' + + ' 1. you did not perform an installation for Firefox before running the script (e.g. `npx puppeteer browsers install firefox`) or\n' + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' ); diff --git a/packages/puppeteer/package.json b/packages/puppeteer/package.json index 79dbd4908dd..e8f904dfa2d 100644 --- a/packages/puppeteer/package.json +++ b/packages/puppeteer/package.json @@ -9,6 +9,7 @@ "automation" ], "type": "commonjs", + "bin": "./lib/esm/puppeteer/node/cli.js", "main": "./lib/cjs/puppeteer/puppeteer.js", "types": "./lib/types.d.ts", "exports": { @@ -77,7 +78,7 @@ ] }, "build:tsc": { - "command": "tsc -b", + "command": "tsc -b && tsx ../../tools/chmod.ts 755 lib/cjs/puppeteer/node/cli.js lib/esm/puppeteer/node/cli.js", "clean": "if-file-deleted", "dependencies": [ "../puppeteer-core:build", diff --git a/packages/puppeteer/src/node/cli.ts b/packages/puppeteer/src/node/cli.ts new file mode 100644 index 00000000000..d0520d7f8f2 --- /dev/null +++ b/packages/puppeteer/src/node/cli.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CLI, Browser} from '@puppeteer/browsers'; +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +import puppeteer from '../puppeteer.js'; + +// TODO: deprecate downloadPath in favour of cacheDirectory. +const cacheDir = + puppeteer.configuration.downloadPath ?? + puppeteer.configuration.cacheDirectory!; + +void new CLI({ + cachePath: cacheDir, + scriptName: 'puppeteer', + prefixCommand: { + cmd: 'browsers', + description: 'Manage browsers of this Puppeteer installation', + }, + allowCachePathOverride: false, + pinnedBrowsers: { + [Browser.CHROME]: PUPPETEER_REVISIONS.chrome, + [Browser.FIREFOX]: PUPPETEER_REVISIONS.firefox, + }, +}).run(process.argv); diff --git a/test/installation/src/puppeteer-cli.spec.ts b/test/installation/src/puppeteer-cli.spec.ts new file mode 100644 index 00000000000..c5815fc8d49 --- /dev/null +++ b/test/installation/src/puppeteer-cli.spec.ts @@ -0,0 +1,68 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'assert'; +import {spawnSync} from 'child_process'; +import {existsSync} from 'fs'; +import {readdir} from 'fs/promises'; +import {join} from 'path'; + +import {configureSandbox} from './sandbox.js'; + +describe('Puppeteer CLI', () => { + configureSandbox({ + dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'], + env: cwd => { + return { + PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'), + PUPPETEER_SKIP_DOWNLOAD: 'true', + }; + }, + }); + + it('can launch', async function () { + const result = spawnSync('npx', ['puppeteer', '--help'], { + // npx is not found without the shell flag on Windows. + shell: process.platform === 'win32', + cwd: this.sandbox, + }); + assert.strictEqual(result.status, 0); + assert.ok( + result.stdout.toString('utf-8').startsWith('puppeteer ') + ); + }); + + it('can download a browser', async function () { + assert.ok(!existsSync(join(this.sandbox, '.cache', 'puppeteer'))); + const result = spawnSync( + 'npx', + ['puppeteer', 'browsers', 'install', 'chrome'], + { + // npx is not found without the shell flag on Windows. + shell: process.platform === 'win32', + cwd: this.sandbox, + env: { + ...process.env, + PUPPETEER_CACHE_DIR: join(this.sandbox, '.cache', 'puppeteer'), + }, + } + ); + assert.strictEqual(result.status, 0); + const files = await readdir(join(this.sandbox, '.cache', 'puppeteer')); + assert.equal(files.length, 1); + assert.equal(files[0], 'chrome'); + }); +});