Skip to content

Commit

Permalink
feat: implement the Puppeteer CLI (#11344)
Browse files Browse the repository at this point in the history
  • Loading branch information
OrKoN committed Nov 23, 2023
1 parent a7fcde9 commit 53fb69b
Show file tree
Hide file tree
Showing 10 changed files with 226 additions and 26 deletions.
26 changes: 21 additions & 5 deletions docs/browsers-api/browsers.cli._constructor_.md
Expand Up @@ -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&lt;{ \[key in [Browser](./browsers.browser.md)\]: string; }&gt;; } | _(Optional)_ |
| rl | readline.Interface | _(Optional)_ |
6 changes: 3 additions & 3 deletions docs/browsers-api/browsers.cli.md
Expand Up @@ -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 <code>CLI</code> class |
| Constructor | Modifiers | Description |
| ---------------------------------------------------------- | --------- | ------------------------------------------------------- |
| [(constructor)(opts, rl)](./browsers.cli._constructor_.md) | | Constructs a new instance of the <code>CLI</code> class |

## Methods

Expand Down
2 changes: 1 addition & 1 deletion docs/contributing.md
Expand Up @@ -32,7 +32,7 @@ again.

```bash
npm install
# Or to download Firefox
# Or to download Firefox by default
PUPPETEER_PRODUCT=firefox npm install
```

Expand Down
6 changes: 6 additions & 0 deletions docs/faq.md
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 79 additions & 14 deletions packages/browsers/src/CLI.ts
Expand Up @@ -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<unknown>): void {
Expand All @@ -98,6 +125,9 @@ export class CLI {
}

#definePathParameter(yargs: Yargs.Argv<unknown>, 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.',
Expand All @@ -111,8 +141,28 @@ export class CLI {

async run(argv: string[]): Promise<void> {
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<unknown>): Yargs.Argv<unknown> {
const latestOrPinned = this.#pinnedBrowsers ? 'pinned' : 'latest';
return yargs
.command(
'install <browser>',
'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: <browser>@<buildID> <path>).',
Expand All @@ -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',
Expand Down Expand Up @@ -176,17 +226,28 @@ 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;
args.platform ??= detectBrowserPlatform();
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,
Expand Down Expand Up @@ -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);
},
Expand All @@ -296,9 +359,7 @@ export class CLI {
}
)
.demandCommand(1)
.help()
.wrap(Math.min(120, yargsInstance.terminalWidth()))
.parse();
.help();
}

#parseBrowser(version: string): Browser {
Expand All @@ -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';
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/puppeteer-core/src/node/ProductLauncher.ts
Expand Up @@ -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.'
);
Expand Down
3 changes: 2 additions & 1 deletion packages/puppeteer/package.json
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions 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);
68 changes: 68 additions & 0 deletions 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 <command>')
);
});

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');
});
});

0 comments on commit 53fb69b

Please sign in to comment.