Node updated. Some todos.

This commit is contained in:
Norm Rasmussen
2024-09-23 20:52:09 -04:00
parent 8bfaca8375
commit f25622067f
2041 changed files with 124145 additions and 110445 deletions

View File

@ -27,14 +27,16 @@ import {
launch,
} from './launch.js';
interface InstallBrowser {
name: Browser;
buildId: string;
}
interface InstallArgs {
browser: {
name: Browser;
buildId: string;
};
browser?: InstallBrowser;
path?: string;
platform?: BrowserPlatform;
baseUrl?: string;
installDeps?: boolean;
}
interface LaunchArgs {
@ -60,7 +62,12 @@ export class CLI {
#rl?: readline.Interface;
#scriptName = '';
#allowCachePathOverride = true;
#pinnedBrowsers?: Partial<{[key in Browser]: string}>;
#pinnedBrowsers?: Partial<{
[key in Browser]: {
buildId: string;
skipDownload: boolean;
};
}>;
#prefixCommand?: {cmd: string; description: string};
constructor(
@ -71,7 +78,12 @@ export class CLI {
scriptName?: string;
prefixCommand?: {cmd: string; description: string};
allowCachePathOverride?: boolean;
pinnedBrowsers?: Partial<{[key in Browser]: string}>;
pinnedBrowsers?: Partial<{
[key in Browser]: {
buildId: string;
skipDownload: boolean;
};
}>;
},
rl?: readline.Interface
) {
@ -152,9 +164,11 @@ export class CLI {
#build(yargs: Yargs.Argv<unknown>): Yargs.Argv<unknown> {
const latestOrPinned = this.#pinnedBrowsers ? 'pinned' : 'latest';
// If there are pinned browsers allow the positional arg to be optional
const browserArgType = this.#pinnedBrowsers ? '[browser]' : '<browser>';
return yargs
.command(
'install <browser>',
`install ${browserArgType}`,
'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>).',
yargs => {
this.#defineBrowserParameter(yargs);
@ -164,6 +178,14 @@ export class CLI {
type: 'string',
desc: 'Base URL to download from',
});
if (this.#pinnedBrowsers) {
yargs.example('$0 install', 'Install all pinned browsers');
}
yargs.option('install-deps', {
type: 'boolean',
desc: 'Whether to attempt installing system dependencies (only supported on Linux, requires root privileges).',
default: false,
});
yargs.example(
'$0 install chrome',
`Install the ${latestOrPinned} available build of the Chrome browser.`
@ -172,6 +194,18 @@ export class CLI {
'$0 install chrome@latest',
'Install the latest available build for the Chrome browser.'
);
yargs.example(
'$0 install chrome@stable',
'Install the latest available build for the Chrome browser from the stable channel.'
);
yargs.example(
'$0 install chrome@beta',
'Install the latest available build for the Chrome browser from the beta channel.'
);
yargs.example(
'$0 install chrome@dev',
'Install the latest available build for the Chrome browser from the dev channel.'
);
yargs.example(
'$0 install chrome@canary',
'Install the latest available build for the Chrome Canary browser.'
@ -210,7 +244,31 @@ export class CLI {
);
yargs.example(
'$0 install firefox',
'Install the latest available build of the Firefox browser.'
'Install the latest nightly available build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@stable',
'Install the latest stable build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@beta',
'Install the latest beta build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@devedition',
'Install the latest devedition build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@esr',
'Install the latest ESR build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@nightly',
'Install the latest nightly build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@stable_111.0.1',
'Install a specific version of the Firefox browser.'
);
yargs.example(
'$0 install firefox --platform mac',
@ -225,45 +283,35 @@ export class CLI {
},
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}`
);
if (this.#pinnedBrowsers && !args.browser) {
// Use allSettled to avoid scenarios that
// a browser may fail early and leave the other
// installation in a faulty state
const result = await Promise.allSettled(
Object.entries(this.#pinnedBrowsers).map(
async ([browser, options]) => {
if (options.skipDownload) {
return;
}
await this.#install({
...argv,
browser: {
name: browser as Browser,
buildId: options.buildId,
},
});
}
)
);
for (const install of result) {
if (install.status === 'rejected') {
throw install.reason;
}
}
args.browser.buildId = pinnedVersion;
} else {
await this.#install(args);
}
args.browser.buildId = await resolveBuildId(
args.browser.name,
args.platform,
args.browser.buildId
);
await install({
browser: args.browser.name,
buildId: args.browser.buildId,
platform: args.platform,
cacheDir: args.path ?? this.#cachePath,
downloadProgressCallback: makeProgressCallback(
args.browser.name,
args.browser.buildId
),
baseUrl: args.baseUrl,
});
console.log(
`${args.browser.name}@${
args.browser.buildId
} ${computeExecutablePath({
browser: args.browser.name,
buildId: args.browser.buildId,
cacheDir: args.path ?? this.#cachePath,
platform: args.platform,
})}`
);
}
)
.command(
@ -364,6 +412,51 @@ export class CLI {
? 'pinned'
: 'latest';
}
async #install(args: InstallArgs) {
args.platform ??= detectBrowserPlatform();
if (!args.browser) {
throw new Error(`No browser arg proveded`);
}
if (!args.platform) {
throw new Error(`Could not resolve the current platform`);
}
if (args.browser.buildId === 'pinned') {
const options = this.#pinnedBrowsers?.[args.browser.name];
if (!options || !options.buildId) {
throw new Error(`No pinned version found for ${args.browser.name}`);
}
args.browser.buildId = options.buildId;
}
const originalBuildId = args.browser.buildId;
args.browser.buildId = await resolveBuildId(
args.browser.name,
args.platform,
args.browser.buildId
);
await install({
browser: args.browser.name,
buildId: args.browser.buildId,
platform: args.platform,
cacheDir: args.path ?? this.#cachePath,
downloadProgressCallback: makeProgressCallback(
args.browser.name,
args.browser.buildId
),
baseUrl: args.baseUrl,
buildIdAlias:
originalBuildId !== args.browser.buildId ? originalBuildId : undefined,
installDeps: args.installDeps,
});
console.log(
`${args.browser.name}@${args.browser.buildId} ${computeExecutablePath({
browser: args.browser.name,
buildId: args.browser.buildId,
cacheDir: args.path ?? this.#cachePath,
platform: args.platform,
})}`
);
}
}
/**
@ -378,7 +471,7 @@ export function makeProgressCallback(
return (downloadedBytes: number, totalBytes: number) => {
if (!progressBar) {
progressBar = new ProgressBar(
`Downloading ${browser} r${buildId} - ${toMegabytes(
`Downloading ${browser} ${buildId} - ${toMegabytes(
totalBytes
)} [:bar] :percent :etas `,
{

View File

@ -8,13 +8,18 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import debug from 'debug';
import {
Browser,
type BrowserPlatform,
executablePathByBrowser,
getVersionComparator,
} from './browser-data/browser-data.js';
import {detectBrowserPlatform} from './detectPlatform.js';
const debugCache = debug('puppeteer:browsers:cache');
/**
* @public
*/
@ -57,6 +62,14 @@ export class InstalledBrowser {
this.buildId
);
}
readMetadata(): Metadata {
return this.#cache.readMetadata(this.browser);
}
writeMetadata(metadata: Metadata): void {
this.#cache.writeMetadata(this.browser, metadata);
}
}
/**
@ -80,6 +93,11 @@ export interface ComputeExecutablePathOptions {
buildId: string;
}
export interface Metadata {
// Maps an alias (canary/latest/dev/etc.) to a buildId.
aliases: Record<string, string>;
}
/**
* The cache used by Puppeteer relies on the following structure:
*
@ -112,6 +130,39 @@ export class Cache {
return path.join(this.#rootDir, browser);
}
metadataFile(browser: Browser): string {
return path.join(this.browserRoot(browser), '.metadata');
}
readMetadata(browser: Browser): Metadata {
const metatadaPath = this.metadataFile(browser);
if (!fs.existsSync(metatadaPath)) {
return {aliases: {}};
}
// TODO: add type-safe parsing.
const data = JSON.parse(fs.readFileSync(metatadaPath, 'utf8'));
if (typeof data !== 'object') {
throw new Error('.metadata is not an object');
}
return data;
}
writeMetadata(browser: Browser, metadata: Metadata): void {
const metatadaPath = this.metadataFile(browser);
fs.mkdirSync(path.dirname(metatadaPath), {recursive: true});
fs.writeFileSync(metatadaPath, JSON.stringify(metadata, null, 2));
}
resolveAlias(browser: Browser, alias: string): string | undefined {
const metadata = this.readMetadata(browser);
if (alias === 'latest') {
return Object.values(metadata.aliases || {})
.sort(getVersionComparator(browser))
.at(-1);
}
return metadata.aliases[alias];
}
installationDir(
browser: Browser,
platform: BrowserPlatform,
@ -134,6 +185,12 @@ export class Cache {
platform: BrowserPlatform,
buildId: string
): void {
const metadata = this.readMetadata(browser);
for (const alias of Object.keys(metadata.aliases)) {
if (metadata.aliases[alias] === buildId) {
delete metadata.aliases[alias];
}
}
fs.rmSync(this.installationDir(browser, platform, buildId), {
force: true,
recursive: true,
@ -180,6 +237,12 @@ export class Cache {
`Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`
);
}
try {
options.buildId =
this.resolveAlias(options.browser, options.buildId) ?? options.buildId;
} catch {
debugCache('could not read .metadata file for the browser');
}
const installationDir = this.installationDir(
options.browser,
options.platform,

View File

@ -43,31 +43,47 @@ export const executablePathByBrowser = {
[Browser.FIREFOX]: firefox.relativeExecutablePath,
};
export const versionComparators = {
[Browser.CHROMEDRIVER]: chromedriver.compareVersions,
[Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.compareVersions,
[Browser.CHROME]: chrome.compareVersions,
[Browser.CHROMIUM]: chromium.compareVersions,
[Browser.FIREFOX]: firefox.compareVersions,
};
export {Browser, BrowserPlatform, ChromeReleaseChannel};
/**
* @public
* @internal
*/
export async function resolveBuildId(
async function resolveBuildIdForBrowserTag(
browser: Browser,
platform: BrowserPlatform,
tag: string
tag: BrowserTag
): Promise<string> {
switch (browser) {
case Browser.FIREFOX:
switch (tag as BrowserTag) {
switch (tag) {
case BrowserTag.LATEST:
return await firefox.resolveBuildId('FIREFOX_NIGHTLY');
return await firefox.resolveBuildId(firefox.FirefoxChannel.NIGHTLY);
case BrowserTag.BETA:
return await firefox.resolveBuildId(firefox.FirefoxChannel.BETA);
case BrowserTag.NIGHTLY:
return await firefox.resolveBuildId(firefox.FirefoxChannel.NIGHTLY);
case BrowserTag.DEVEDITION:
return await firefox.resolveBuildId(
firefox.FirefoxChannel.DEVEDITION
);
case BrowserTag.STABLE:
return await firefox.resolveBuildId(firefox.FirefoxChannel.STABLE);
case BrowserTag.ESR:
return await firefox.resolveBuildId(firefox.FirefoxChannel.ESR);
case BrowserTag.CANARY:
case BrowserTag.DEV:
case BrowserTag.STABLE:
throw new Error(
`${tag} is not supported for ${browser}. Use 'latest' instead.`
);
throw new Error(`${tag.toUpperCase()} is not available for Firefox`);
}
case Browser.CHROME: {
switch (tag as BrowserTag) {
switch (tag) {
case BrowserTag.LATEST:
return await chrome.resolveBuildId(ChromeReleaseChannel.CANARY);
case BrowserTag.BETA:
@ -78,13 +94,11 @@ export async function resolveBuildId(
return await chrome.resolveBuildId(ChromeReleaseChannel.DEV);
case BrowserTag.STABLE:
return await chrome.resolveBuildId(ChromeReleaseChannel.STABLE);
default:
const result = await chrome.resolveBuildId(tag);
if (result) {
return result;
}
case BrowserTag.NIGHTLY:
case BrowserTag.DEVEDITION:
case BrowserTag.ESR:
throw new Error(`${tag.toUpperCase()} is not available for Chrome`);
}
return tag;
}
case Browser.CHROMEDRIVER: {
switch (tag) {
@ -97,13 +111,13 @@ export async function resolveBuildId(
return await chromedriver.resolveBuildId(ChromeReleaseChannel.DEV);
case BrowserTag.STABLE:
return await chromedriver.resolveBuildId(ChromeReleaseChannel.STABLE);
default:
const result = await chromedriver.resolveBuildId(tag);
if (result) {
return result;
}
case BrowserTag.NIGHTLY:
case BrowserTag.DEVEDITION:
case BrowserTag.ESR:
throw new Error(
`${tag.toUpperCase()} is not available for ChromeDriver`
);
}
return tag;
}
case Browser.CHROMEHEADLESSSHELL: {
switch (tag) {
@ -124,29 +138,68 @@ export async function resolveBuildId(
return await chromeHeadlessShell.resolveBuildId(
ChromeReleaseChannel.STABLE
);
default:
const result = await chromeHeadlessShell.resolveBuildId(tag);
if (result) {
return result;
}
case BrowserTag.NIGHTLY:
case BrowserTag.DEVEDITION:
case BrowserTag.ESR:
throw new Error(`${tag} is not available for chrome-headless-shell`);
}
return tag;
}
case Browser.CHROMIUM:
switch (tag as BrowserTag) {
switch (tag) {
case BrowserTag.LATEST:
return await chromium.resolveBuildId(platform);
case BrowserTag.BETA:
case BrowserTag.NIGHTLY:
case BrowserTag.CANARY:
case BrowserTag.DEV:
case BrowserTag.DEVEDITION:
case BrowserTag.BETA:
case BrowserTag.STABLE:
case BrowserTag.ESR:
throw new Error(
`${tag} is not supported for ${browser}. Use 'latest' instead.`
`${tag} is not supported for Chromium. Use 'latest' instead.`
);
}
}
// We assume the tag is the buildId if it didn't match any keywords.
return tag;
}
/**
* @public
*/
export async function resolveBuildId(
browser: Browser,
platform: BrowserPlatform,
tag: string
): Promise<string> {
const browserTag = tag as BrowserTag;
if (Object.values(BrowserTag).includes(browserTag)) {
return await resolveBuildIdForBrowserTag(browser, platform, browserTag);
}
switch (browser) {
case Browser.FIREFOX:
return tag;
case Browser.CHROME:
const chromeResult = await chrome.resolveBuildId(tag);
if (chromeResult) {
return chromeResult;
}
return tag;
case Browser.CHROMEDRIVER:
const chromeDriverResult = await chromedriver.resolveBuildId(tag);
if (chromeDriverResult) {
return chromeDriverResult;
}
return tag;
case Browser.CHROMEHEADLESSSHELL:
const chromeHeadlessShellResult =
await chromeHeadlessShell.resolveBuildId(tag);
if (chromeHeadlessShellResult) {
return chromeHeadlessShellResult;
}
return tag;
case Browser.CHROMIUM:
return tag;
}
}
/**
@ -185,3 +238,15 @@ export function resolveSystemExecutablePath(
return chrome.resolveSystemExecutablePath(platform, channel);
}
}
/**
* Returns a version comparator for the given browser that can be used to sort
* browser versions.
*
* @public
*/
export function getVersionComparator(
browser: Browser
): (a: string, b: string) => number {
return versionComparators[browser];
}

View File

@ -25,7 +25,7 @@ function folder(platform: BrowserPlatform): string {
export function resolveDownloadUrl(
platform: BrowserPlatform,
buildId: string,
baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing'
baseUrl = 'https://storage.googleapis.com/chrome-for-testing-public'
): string {
return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`;
}
@ -66,4 +66,4 @@ export function relativeExecutablePath(
}
}
export {resolveBuildId} from './chrome.js';
export {resolveBuildId, compareVersions} from './chrome.js';

View File

@ -6,6 +6,8 @@
import path from 'path';
import semver from 'semver';
import {getJSON} from '../httpUtil.js';
import {BrowserPlatform, ChromeReleaseChannel} from './types.js';
@ -28,7 +30,7 @@ function folder(platform: BrowserPlatform): string {
export function resolveDownloadUrl(
platform: BrowserPlatform,
buildId: string,
baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing'
baseUrl = 'https://storage.googleapis.com/chrome-for-testing-public'
): string {
return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`;
}
@ -193,3 +195,19 @@ export function resolveSystemExecutablePath(
`Unable to detect browser executable path for '${channel}' on ${platform}.`
);
}
export function compareVersions(a: string, b: string): number {
if (!semver.valid(a)) {
throw new Error(`Version ${a} is not a valid semver version`);
}
if (!semver.valid(b)) {
throw new Error(`Version ${b} is not a valid semver version`);
}
if (semver.gt(a, b)) {
return 1;
} else if (semver.lt(a, b)) {
return -1;
} else {
return 0;
}
}

View File

@ -25,7 +25,7 @@ function folder(platform: BrowserPlatform): string {
export function resolveDownloadUrl(
platform: BrowserPlatform,
buildId: string,
baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing'
baseUrl = 'https://storage.googleapis.com/chrome-for-testing-public'
): string {
return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`;
}
@ -53,4 +53,4 @@ export function relativeExecutablePath(
}
}
export {resolveBuildId} from './chrome.js';
export {resolveBuildId, compareVersions} from './chrome.js';

View File

@ -86,3 +86,7 @@ export async function resolveBuildId(
)
);
}
export function compareVersions(a: string, b: string): number {
return Number(a) - Number(b);
}

View File

@ -11,7 +11,7 @@ import {getJSON} from '../httpUtil.js';
import {BrowserPlatform, type ProfileOptions} from './types.js';
function archive(platform: BrowserPlatform, buildId: string): string {
function archiveNightly(platform: BrowserPlatform, buildId: string): string {
switch (platform) {
case BrowserPlatform.LINUX:
return `firefox-${buildId}.en-US.${platform}-x86_64.tar.bz2`;
@ -24,11 +24,63 @@ function archive(platform: BrowserPlatform, buildId: string): string {
}
}
function archive(platform: BrowserPlatform, buildId: string): string {
switch (platform) {
case BrowserPlatform.LINUX:
return `firefox-${buildId}.tar.bz2`;
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return `Firefox ${buildId}.dmg`;
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return `Firefox Setup ${buildId}.exe`;
}
}
function platformName(platform: BrowserPlatform): string {
switch (platform) {
case BrowserPlatform.LINUX:
return `linux-x86_64`;
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return `mac`;
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return platform;
}
}
function parseBuildId(buildId: string): [FirefoxChannel, string] {
for (const value of Object.values(FirefoxChannel)) {
if (buildId.startsWith(value + '_')) {
buildId = buildId.substring(value.length + 1);
return [value, buildId];
}
}
// Older versions do not have channel as the prefix.«
return [FirefoxChannel.NIGHTLY, buildId];
}
export function resolveDownloadUrl(
platform: BrowserPlatform,
buildId: string,
baseUrl = 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central'
baseUrl?: string
): string {
const [channel] = parseBuildId(buildId);
switch (channel) {
case FirefoxChannel.NIGHTLY:
baseUrl ??=
'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central';
break;
case FirefoxChannel.DEVEDITION:
baseUrl ??= 'https://archive.mozilla.org/pub/devedition/releases';
break;
case FirefoxChannel.BETA:
case FirefoxChannel.STABLE:
case FirefoxChannel.ESR:
baseUrl ??= 'https://archive.mozilla.org/pub/firefox/releases';
break;
}
return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`;
}
@ -36,36 +88,88 @@ export function resolveDownloadPath(
platform: BrowserPlatform,
buildId: string
): string[] {
return [archive(platform, buildId)];
const [channel, resolvedBuildId] = parseBuildId(buildId);
switch (channel) {
case FirefoxChannel.NIGHTLY:
return [archiveNightly(platform, resolvedBuildId)];
case FirefoxChannel.DEVEDITION:
case FirefoxChannel.BETA:
case FirefoxChannel.STABLE:
case FirefoxChannel.ESR:
return [
resolvedBuildId,
platformName(platform),
'en-US',
archive(platform, resolvedBuildId),
];
}
}
export function relativeExecutablePath(
platform: BrowserPlatform,
_buildId: string
buildId: string
): string {
switch (platform) {
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox');
case BrowserPlatform.LINUX:
return path.join('firefox', 'firefox');
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return path.join('firefox', 'firefox.exe');
const [channel] = parseBuildId(buildId);
switch (channel) {
case FirefoxChannel.NIGHTLY:
switch (platform) {
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return path.join(
'Firefox Nightly.app',
'Contents',
'MacOS',
'firefox'
);
case BrowserPlatform.LINUX:
return path.join('firefox', 'firefox');
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return path.join('firefox', 'firefox.exe');
}
case FirefoxChannel.BETA:
case FirefoxChannel.DEVEDITION:
case FirefoxChannel.ESR:
case FirefoxChannel.STABLE:
switch (platform) {
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return path.join('Firefox.app', 'Contents', 'MacOS', 'firefox');
case BrowserPlatform.LINUX:
return path.join('firefox', 'firefox');
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return path.join('core', 'firefox.exe');
}
}
}
export enum FirefoxChannel {
STABLE = 'stable',
ESR = 'esr',
DEVEDITION = 'devedition',
BETA = 'beta',
NIGHTLY = 'nightly',
}
export async function resolveBuildId(
channel: 'FIREFOX_NIGHTLY' = 'FIREFOX_NIGHTLY'
channel: FirefoxChannel = FirefoxChannel.NIGHTLY
): Promise<string> {
const channelToVersionKey = {
[FirefoxChannel.ESR]: 'FIREFOX_ESR',
[FirefoxChannel.STABLE]: 'LATEST_FIREFOX_VERSION',
[FirefoxChannel.DEVEDITION]: 'FIREFOX_DEVEDITION',
[FirefoxChannel.BETA]: 'FIREFOX_DEVEDITION',
[FirefoxChannel.NIGHTLY]: 'FIREFOX_NIGHTLY',
};
const versions = (await getJSON(
new URL('https://product-details.mozilla.org/1.0/firefox_versions.json')
)) as Record<string, string>;
const version = versions[channel];
const version = versions[channelToVersionKey[channel]];
if (!version) {
throw new Error(`Channel ${channel} is not found.`);
}
return version;
return channel + '_' + version;
}
export async function createProfile(options: ProfileOptions): Promise<void> {
@ -74,7 +178,7 @@ export async function createProfile(options: ProfileOptions): Promise<void> {
recursive: true,
});
}
await writePreferences({
await syncPreferences({
preferences: {
...defaultProfilePreferences(options.preferences),
...options.preferences,
@ -235,10 +339,6 @@ function defaultProfilePreferences(
// Disable the GFX sanity window
'media.sanity-test.disabled': true,
// Prevent various error message on the console
// jest-puppeteer asserts that no error message is emitted by the console
'network.cookie.cookieBehavior': 0,
// Disable experimental feature that is only available in Nightly
'network.cookie.sameSite.laxByDefault': false,
@ -301,30 +401,43 @@ function defaultProfilePreferences(
return Object.assign(defaultPrefs, extraPrefs);
}
async function backupFile(input: string): Promise<void> {
if (!fs.existsSync(input)) {
return;
}
await fs.promises.copyFile(input, input + '.puppeteer');
}
/**
* Populates the user.js file with custom preferences as needed to allow
* Firefox's CDP support to properly function. These preferences will be
* Firefox's support to properly function. These preferences will be
* automatically copied over to prefs.js during startup of Firefox. To be
* able to restore the original values of preferences a backup of prefs.js
* will be created.
*
* @param prefs - List of preferences to add.
* @param profilePath - Firefox profile to write the preferences to.
*/
async function writePreferences(options: ProfileOptions): Promise<void> {
async function syncPreferences(options: ProfileOptions): Promise<void> {
const prefsPath = path.join(options.path, 'prefs.js');
const userPath = path.join(options.path, 'user.js');
const lines = Object.entries(options.preferences).map(([key, value]) => {
return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`;
});
await fs.promises.writeFile(
path.join(options.path, 'user.js'),
lines.join('\n')
);
// Create a backup of the preferences file if it already exitsts.
const prefsPath = path.join(options.path, 'prefs.js');
if (fs.existsSync(prefsPath)) {
const prefsBackupPath = path.join(options.path, 'prefs.js.puppeteer');
await fs.promises.copyFile(prefsPath, prefsBackupPath);
// Use allSettled to prevent corruption.
const result = await Promise.allSettled([
backupFile(userPath).then(async () => {
await fs.promises.writeFile(userPath, lines.join('\n'));
}),
backupFile(prefsPath),
]);
for (const command of result) {
if (command.status === 'rejected') {
throw command.reason;
}
}
}
export function compareVersions(a: string, b: string): number {
// TODO: this is a not very reliable check.
return parseInt(a.replace('.', ''), 16) - parseInt(b.replace('.', ''), 16);
}

View File

@ -36,9 +36,12 @@ export enum BrowserPlatform {
*/
export enum BrowserTag {
CANARY = 'canary',
NIGHTLY = 'nightly',
BETA = 'beta',
DEV = 'dev',
DEVEDITION = 'devedition',
STABLE = 'stable',
ESR = 'esr',
LATEST = 'latest',
}

View File

@ -4,18 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {exec as execChildProcess} from 'child_process';
import {spawnSync} from 'child_process';
import {createReadStream} from 'fs';
import {mkdir, readdir} from 'fs/promises';
import * as path from 'path';
import {promisify} from 'util';
import extractZip from 'extract-zip';
import tar from 'tar-fs';
import bzip from 'unbzip2-stream';
const exec = promisify(execChildProcess);
/**
* @internal
*/
@ -30,6 +27,18 @@ export async function unpackArchive(
} else if (archivePath.endsWith('.dmg')) {
await mkdir(folderPath);
await installDMG(archivePath, folderPath);
} else if (archivePath.endsWith('.exe')) {
// Firefox on Windows.
const result = spawnSync(archivePath, [`/ExtractDir=${folderPath}`], {
env: {
__compat_layer: 'RunAsInvoker',
},
});
if (result.status !== 0) {
throw new Error(
`Failed to extract ${archivePath} to ${folderPath}: ${result.output}`
);
}
} else {
throw new Error(`Unsupported archive format: ${archivePath}`);
}
@ -52,11 +61,14 @@ function extractTar(tarPath: string, folderPath: string): Promise<void> {
* @internal
*/
async function installDMG(dmgPath: string, folderPath: string): Promise<void> {
const {stdout} = await exec(
`hdiutil attach -nobrowse -noautoopen "${dmgPath}"`
);
const {stdout} = spawnSync(`hdiutil`, [
'attach',
'-nobrowse',
'-noautoopen',
dmgPath,
]);
const volumes = stdout.match(/\/Volumes\/(.*)/m);
const volumes = stdout.toString('utf8').match(/\/Volumes\/(.*)/m);
if (!volumes) {
throw new Error(`Could not find volume path in ${stdout}`);
}
@ -72,8 +84,8 @@ async function installDMG(dmgPath: string, folderPath: string): Promise<void> {
}
const mountedPath = path.join(mountPath!, appName);
await exec(`cp -R "${mountedPath}" "${folderPath}"`);
spawnSync('cp', ['-R', mountedPath, folderPath]);
} finally {
await exec(`hdiutil detach "${mountPath}" -quiet`);
spawnSync('hdiutil', ['detach', mountPath, '-quiet']);
}
}

View File

@ -54,6 +54,9 @@ export function httpRequest(
res.headers.location
) {
httpRequest(new URL(res.headers.location), method, response);
// consume response data to free up memory
// And prevents the connection from being kept alive
res.resume();
} else {
response(res);
}

View File

@ -5,21 +5,22 @@
*/
import assert from 'assert';
import {existsSync} from 'fs';
import {spawnSync} from 'child_process';
import {existsSync, readFileSync} from 'fs';
import {mkdir, unlink} from 'fs/promises';
import os from 'os';
import path from 'path';
import {
type Browser,
type BrowserPlatform,
Browser,
BrowserPlatform,
downloadUrls,
} from './browser-data/browser-data.js';
import {Cache, InstalledBrowser} from './Cache.js';
import {debug} from './debug.js';
import {detectBrowserPlatform} from './detectPlatform.js';
import {unpackArchive} from './fileUtil.js';
import {downloadFile, headHttpRequest} from './httpUtil.js';
import {downloadFile, getJSON, headHttpRequest} from './httpUtil.js';
const debugInstall = debug('puppeteer:browsers:install');
@ -62,6 +63,13 @@ export interface InstallOptions {
* binaries and they are used for caching.
*/
buildId: string;
/**
* An alias for the provided `buildId`. It will be used to maintain local
* metadata to support aliases in the `launch` command.
*
* @example 'canary'
*/
buildIdAlias?: string;
/**
* Provides information about the progress of the download.
*/
@ -74,7 +82,7 @@ export interface InstallOptions {
*
* @defaultValue Either
*
* - https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or
* - https://storage.googleapis.com/chrome-for-testing-public or
* - https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central
*
*/
@ -85,15 +93,41 @@ export interface InstallOptions {
* @defaultValue `true`
*/
unpack?: boolean;
/**
* @internal
* @defaultValue `false`
*/
forceFallbackForTesting?: boolean;
/**
* Whether to attempt to install system-level dependencies required
* for the browser.
*
* Only supported for Chrome on Debian or Ubuntu.
* Requires system-level privileges to run `apt-get`.
*
* @defaultValue `false`
*/
installDeps?: boolean;
}
/**
* Downloads and unpacks the browser archive according to the
* {@link InstallOptions}.
*
* @returns a {@link InstalledBrowser} instance.
*
* @public
*/
export function install(
options: InstallOptions & {unpack?: true}
): Promise<InstalledBrowser>;
/**
* Downloads the browser archive according to the {@link InstallOptions} without
* unpacking.
*
* @returns the absolute path to the archive.
*
* @public
*/
export function install(
@ -115,6 +149,113 @@ export async function install(
options.buildId,
options.baseUrl
);
try {
return await installUrl(url, options);
} catch (err) {
// If custom baseUrl is provided, do not fall back to CfT dashboard.
if (options.baseUrl && !options.forceFallbackForTesting) {
throw err;
}
debugInstall(`Error downloading from ${url}.`);
switch (options.browser) {
case Browser.CHROME:
case Browser.CHROMEDRIVER:
case Browser.CHROMEHEADLESSSHELL: {
debugInstall(
`Trying to find download URL via https://googlechromelabs.github.io/chrome-for-testing.`
);
interface Version {
downloads: Record<string, Array<{platform: string; url: string}>>;
}
const version = (await getJSON(
new URL(
`https://googlechromelabs.github.io/chrome-for-testing/${options.buildId}.json`
)
)) as Version;
let platform = '';
switch (options.platform) {
case BrowserPlatform.LINUX:
platform = 'linux64';
break;
case BrowserPlatform.MAC_ARM:
platform = 'mac-arm64';
break;
case BrowserPlatform.MAC:
platform = 'mac-x64';
break;
case BrowserPlatform.WIN32:
platform = 'win32';
break;
case BrowserPlatform.WIN64:
platform = 'win64';
break;
}
const url = version.downloads[options.browser]?.find(link => {
return link['platform'] === platform;
})?.url;
if (url) {
debugInstall(`Falling back to downloading from ${url}.`);
return await installUrl(new URL(url), options);
}
throw err;
}
default:
throw err;
}
}
}
async function installDeps(installedBrowser: InstalledBrowser) {
if (
process.platform !== 'linux' ||
installedBrowser.platform !== BrowserPlatform.LINUX
) {
return;
}
// Currently, only Debian-like deps are supported.
const depsPath = path.join(
path.dirname(installedBrowser.executablePath),
'deb.deps'
);
if (!existsSync(depsPath)) {
debugInstall(`deb.deps file was not found at ${depsPath}`);
return;
}
const data = readFileSync(depsPath, 'utf-8').split('\n').join(',');
if (process.getuid?.() !== 0) {
throw new Error('Installing system dependencies requires root privileges');
}
let result = spawnSync('apt-get', ['-v']);
if (result.status !== 0) {
throw new Error(
'Failed to install system dependencies: apt-get does not seem to be available'
);
}
debugInstall(`Trying to install dependencies: ${data}`);
result = spawnSync('apt-get', [
'satisfy',
'-y',
data,
'--no-install-recommends',
]);
if (result.status !== 0) {
throw new Error(
`Failed to install system dependencies: status=${result.status},error=${result.error},stdout=${result.stdout.toString('utf8')},stderr=${result.stderr.toString('utf8')}`
);
}
debugInstall(`Installed system dependencies ${data}`);
}
async function installUrl(
url: URL,
options: InstallOptions
): Promise<InstalledBrowser | string> {
options.platform ??= detectBrowserPlatform();
if (!options.platform) {
throw new Error(
`Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`
);
}
const fileName = url.toString().split('/').pop();
assert(fileName, `A malformed download URL was found: ${url}.`);
const cache = new Cache(options.cacheDir);
@ -140,15 +281,26 @@ export async function install(
options.platform,
options.buildId
);
if (existsSync(outputPath)) {
return new InstalledBrowser(
cache,
options.browser,
options.buildId,
options.platform
);
}
try {
if (existsSync(outputPath)) {
const installedBrowser = new InstalledBrowser(
cache,
options.browser,
options.buildId,
options.platform
);
if (!existsSync(installedBrowser.executablePath)) {
throw new Error(
`The browser folder (${outputPath}) exists but the executable (${installedBrowser.executablePath}) is missing`
);
}
await runSetup(installedBrowser);
if (options.installDeps) {
await installDeps(installedBrowser);
}
return installedBrowser;
}
debugInstall(`Downloading binary from ${url}`);
try {
debugTime('download');
@ -164,17 +316,59 @@ export async function install(
} finally {
debugTimeEnd('extract');
}
const installedBrowser = new InstalledBrowser(
cache,
options.browser,
options.buildId,
options.platform
);
if (options.buildIdAlias) {
const metadata = installedBrowser.readMetadata();
metadata.aliases[options.buildIdAlias] = options.buildId;
installedBrowser.writeMetadata(metadata);
}
await runSetup(installedBrowser);
if (options.installDeps) {
await installDeps(installedBrowser);
}
return installedBrowser;
} finally {
if (existsSync(archivePath)) {
await unlink(archivePath);
}
}
return new InstalledBrowser(
cache,
options.browser,
options.buildId,
options.platform
);
}
async function runSetup(installedBrowser: InstalledBrowser): Promise<void> {
// On Windows for Chrome invoke setup.exe to configure sandboxes.
if (
(installedBrowser.platform === BrowserPlatform.WIN32 ||
installedBrowser.platform === BrowserPlatform.WIN64) &&
installedBrowser.browser === Browser.CHROME &&
installedBrowser.platform === detectBrowserPlatform()
) {
try {
debugTime('permissions');
const browserDir = path.dirname(installedBrowser.executablePath);
const setupExePath = path.join(browserDir, 'setup.exe');
if (!existsSync(setupExePath)) {
return;
}
spawnSync(
path.join(browserDir, 'setup.exe'),
[`--configure-browser-in-directory=` + browserDir],
{
shell: true,
}
);
// TODO: Handle error here. Currently the setup.exe sometimes
// errors although it sets the permissions correctly.
} finally {
debugTimeEnd('permissions');
}
}
}
/**

View File

@ -104,19 +104,69 @@ export function computeSystemExecutablePath(options: SystemOptions): string {
* @public
*/
export interface LaunchOptions {
/**
* Absolute path to the browser's executable.
*/
executablePath: string;
/**
* Configures stdio streams to open two additional streams for automation over
* those streams instead of WebSocket.
*
* @defaultValue `false`.
*/
pipe?: boolean;
/**
* If true, forwards the browser's process stdout and stderr to the Node's
* process stdout and stderr.
*
* @defaultValue `false`.
*/
dumpio?: boolean;
/**
* Additional arguments to pass to the executable when launching.
*/
args?: string[];
/**
* Environment variables to set for the browser process.
*/
env?: Record<string, string | undefined>;
/**
* Handles SIGINT in the Node process and tries to kill the browser process.
*
* @defaultValue `true`.
*/
handleSIGINT?: boolean;
/**
* Handles SIGTERM in the Node process and tries to gracefully close the browser
* process.
*
* @defaultValue `true`.
*/
handleSIGTERM?: boolean;
/**
* Handles SIGHUP in the Node process and tries to gracefully close the browser process.
*
* @defaultValue `true`.
*/
handleSIGHUP?: boolean;
/**
* Whether to spawn process in the {@link https://nodejs.org/api/child_process.html#optionsdetached | detached}
* mode.
*
* @defaultValue `true` except on Windows.
*/
detached?: boolean;
/**
* A callback to run after the browser process exits or before the process
* will be closed via the {@link Process.close} call (including when handling
* signals). The callback is only run once.
*/
onExit?: () => Promise<void>;
}
/**
* Launches a browser process according to {@link LaunchOptions}.
*
* @public
*/
export function launch(opts: LaunchOptions): Process {
@ -135,6 +185,59 @@ export const CDP_WEBSOCKET_ENDPOINT_REGEX =
export const WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX =
/^WebDriver BiDi listening on (ws:\/\/.*)$/;
type EventHandler = (...args: any[]) => void;
const processListeners = new Map<string, EventHandler[]>();
const dispatchers = {
exit: (...args: any[]) => {
processListeners.get('exit')?.forEach(handler => {
return handler(...args);
});
},
SIGINT: (...args: any[]) => {
processListeners.get('SIGINT')?.forEach(handler => {
return handler(...args);
});
},
SIGHUP: (...args: any[]) => {
processListeners.get('SIGHUP')?.forEach(handler => {
return handler(...args);
});
},
SIGTERM: (...args: any[]) => {
processListeners.get('SIGTERM')?.forEach(handler => {
return handler(...args);
});
},
};
function subscribeToProcessEvent(
event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM',
handler: EventHandler
): void {
const listeners = processListeners.get(event) || [];
if (listeners.length === 0) {
process.on(event, dispatchers[event]);
}
listeners.push(handler);
processListeners.set(event, listeners);
}
function unsubscribeFromProcessEvent(
event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM',
handler: EventHandler
): void {
const listeners = processListeners.get(event) || [];
const existingListenerIdx = listeners.indexOf(handler);
if (existingListenerIdx === -1) {
return;
}
listeners.splice(existingListenerIdx, 1);
processListeners.set(event, listeners);
if (listeners.length === 0) {
process.off(event, dispatchers[event]);
}
}
/**
* @public
*/
@ -201,15 +304,15 @@ export class Process {
this.#browserProcess.stderr?.pipe(process.stderr);
this.#browserProcess.stdout?.pipe(process.stdout);
}
process.on('exit', this.#onDriverProcessExit);
subscribeToProcessEvent('exit', this.#onDriverProcessExit);
if (opts.handleSIGINT) {
process.on('SIGINT', this.#onDriverProcessSignal);
subscribeToProcessEvent('SIGINT', this.#onDriverProcessSignal);
}
if (opts.handleSIGTERM) {
process.on('SIGTERM', this.#onDriverProcessSignal);
subscribeToProcessEvent('SIGTERM', this.#onDriverProcessSignal);
}
if (opts.handleSIGHUP) {
process.on('SIGHUP', this.#onDriverProcessSignal);
subscribeToProcessEvent('SIGHUP', this.#onDriverProcessSignal);
}
if (opts.onExit) {
this.#onExitHook = opts.onExit;
@ -262,10 +365,10 @@ export class Process {
}
#clearListeners(): void {
process.off('exit', this.#onDriverProcessExit);
process.off('SIGINT', this.#onDriverProcessSignal);
process.off('SIGTERM', this.#onDriverProcessSignal);
process.off('SIGHUP', this.#onDriverProcessSignal);
unsubscribeFromProcessEvent('exit', this.#onDriverProcessExit);
unsubscribeFromProcessEvent('SIGINT', this.#onDriverProcessSignal);
unsubscribeFromProcessEvent('SIGTERM', this.#onDriverProcessSignal);
unsubscribeFromProcessEvent('SIGHUP', this.#onDriverProcessSignal);
}
#onDriverProcessExit = (_code: number) => {

View File

@ -37,6 +37,7 @@ export {
BrowserPlatform,
ChromeReleaseChannel,
createProfile,
getVersionComparator,
} from './browser-data/browser-data.js';
export {CLI, makeProgressCallback} from './CLI.js';
export {Cache, InstalledBrowser} from './Cache.js';