chore: validate deps during install (#28932)

Motivation: On Windows we call around 50 times `PrintDeps.exe` which
takes on a very fast machine 500+ms. On Linux we do it around 120 times
(`ldd`) which takes around 150ms.

This change validates the dependencies once on browser install (`npx
playwright install`). In case its failing, it will emit a warning, in
case of a success, it will create a marker file that the binary has been
validated. For future `launch()` calls, we'll read this file and if
exists, we'll not validate again. Otherwise we'll validate again.

Note: If the marker file is older than 30 days, the browser will be
validated again.
This commit is contained in:
Max Schmitt 2024-01-25 20:55:53 +01:00 committed by GitHub
parent 6a14b1dc51
commit 4c4789c740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 140 additions and 42 deletions

View File

@ -160,6 +160,10 @@ program
} else {
const forceReinstall = hasNoArguments ? false : !!options.force;
await registry.install(executables, forceReinstall);
await registry.validateHostRequirementsForExecutablesIfNeeded(executables, process.env.PW_LANG_NAME || 'javascript').catch((e: Error) => {
e.name = 'Playwright Host validation warning';
console.error(e);
});
}
} catch (e) {
console.log(`Failed to install browsers\n${e}`);

View File

@ -180,7 +180,7 @@ export abstract class BrowserType extends SdkObject {
if (!registryExecutable || registryExecutable.browserName !== this._name)
throw new Error(`Unsupported ${this._name} channel "${options.channel}"`);
executable = registryExecutable.executablePathOrDie(this.attribution.playwright.options.sdkLanguage);
await registryExecutable.validateHostRequirements(this.attribution.playwright.options.sdkLanguage);
await registry.validateHostRequirementsForExecutablesIfNeeded([registryExecutable], this.attribution.playwright.options.sdkLanguage);
}
const waitForWSEndpoint = (options.useWebSocket || options.args?.some(a => a.startsWith('--remote-debugging-port'))) ? new ManualPromise<string>() : undefined;

View File

@ -230,17 +230,17 @@ export async function validateDependenciesLinux(sdkLanguage: string, linuxLddDir
const pwVersion = getPlaywrightVersion();
const requiredDockerImage = dockerInfo.dockerImageName.replace(dockerInfo.driverVersion, pwVersion);
errorLines.push(...[
`This is most likely due to docker image version not matching Playwright version:`,
`- Playwright: ${pwVersion}`,
`- Docker: ${dockerInfo.driverVersion}`,
`This is most likely due to Docker image version not matching Playwright version:`,
`- Playwright : ${pwVersion}`,
`- Docker image: ${dockerInfo.driverVersion}`,
``,
`Either:`,
`- (recommended) use docker image "${requiredDockerImage}"`,
`- (alternative 1) run the following command inside docker to install missing dependencies:`,
`- (recommended) use Docker image "${requiredDockerImage}"`,
`- (alternative 1) run the following command inside Docker to install missing dependencies:`,
``,
` ${maybeSudo}${buildPlaywrightCLICommand(sdkLanguage, 'install-deps')}`,
``,
`- (alternative 2) use apt inside docker:`,
`- (alternative 2) use apt inside Docker:`,
``,
` ${maybeSudo}apt-get install ${[...missingPackages].join('\\\n ')}`,
``,

View File

@ -20,7 +20,6 @@ import path from 'path';
import * as util from 'util';
import * as fs from 'fs';
import { lockfile } from '../../utilsBundle';
import { getLinuxDistributionInfo } from '../../utils/linuxUtils';
import { fetchData } from '../../utils/network';
import { getEmbedderName } from '../../utils/userAgent';
import { getFromENV, getAsBooleanFromENV, calculateSha1, wrapInASCIIBox, getPackageManagerExecCommand } from '../../utils';
@ -32,6 +31,7 @@ import { transformCommandsForRoot, dockerVersion, readDockerVersionSync } from '
import { installDependenciesLinux, installDependenciesWindows, validateDependenciesLinux, validateDependenciesWindows } from './dependencies';
import { downloadBrowserWithProgressBar, logPolitely } from './browserFetcher';
export { writeDockerVersion } from './dependencies';
import { debugLogger } from '../../common/debugLogger';
const PACKAGE_PATH = path.join(__dirname, '..', '..', '..');
const BIN_PATH = path.join(__dirname, '..', '..', '..', 'bin');
@ -382,7 +382,7 @@ export interface Executable {
browserVersion?: string,
executablePathOrDie(sdkLanguage: string): string;
executablePath(sdkLanguage: string): string | undefined;
validateHostRequirements(sdkLanguage: string): Promise<void>;
_validateHostRequirements(sdkLanguage: string): Promise<void>;
}
interface ExecutableImpl extends Executable {
@ -445,7 +445,7 @@ export class Registry {
executablePath: () => chromiumExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium', chromiumExecutable, chromium.installByDefault, sdkLanguage),
installType: chromium.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromium.dir, ['chrome-linux'], [], ['chrome-win']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromium.dir, ['chrome-linux'], [], ['chrome-win']),
downloadURLs: this._downloadURLs(chromium),
browserVersion: chromium.browserVersion,
_install: () => this._downloadExecutable(chromium, chromiumExecutable),
@ -463,7 +463,7 @@ export class Registry {
executablePath: () => chromiumWithSymbolsExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium-with-symbols', chromiumWithSymbolsExecutable, chromiumWithSymbols.installByDefault, sdkLanguage),
installType: chromiumWithSymbols.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromiumWithSymbols.dir, ['chrome-linux'], [], ['chrome-win']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromiumWithSymbols.dir, ['chrome-linux'], [], ['chrome-win']),
downloadURLs: this._downloadURLs(chromiumWithSymbols),
browserVersion: chromiumWithSymbols.browserVersion,
_install: () => this._downloadExecutable(chromiumWithSymbols, chromiumWithSymbolsExecutable),
@ -481,7 +481,7 @@ export class Registry {
executablePath: () => chromiumTipOfTreeExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium-tip-of-tree', chromiumTipOfTreeExecutable, chromiumTipOfTree.installByDefault, sdkLanguage),
installType: chromiumTipOfTree.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromiumTipOfTree.dir, ['chrome-linux'], [], ['chrome-win']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromiumTipOfTree.dir, ['chrome-linux'], [], ['chrome-win']),
downloadURLs: this._downloadURLs(chromiumTipOfTree),
browserVersion: chromiumTipOfTree.browserVersion,
_install: () => this._downloadExecutable(chromiumTipOfTree, chromiumTipOfTreeExecutable),
@ -567,7 +567,7 @@ export class Registry {
executablePath: () => firefoxExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('firefox', firefoxExecutable, firefox.installByDefault, sdkLanguage),
installType: firefox.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'firefox', firefox.dir, ['firefox'], [], ['firefox']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'firefox', firefox.dir, ['firefox'], [], ['firefox']),
downloadURLs: this._downloadURLs(firefox),
browserVersion: firefox.browserVersion,
_install: () => this._downloadExecutable(firefox, firefoxExecutable),
@ -585,7 +585,7 @@ export class Registry {
executablePath: () => firefoxAsanExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('firefox-asan', firefoxAsanExecutable, firefoxAsan.installByDefault, sdkLanguage),
installType: firefoxAsan.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'firefox', firefoxAsan.dir, ['firefox'], [], ['firefox']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'firefox', firefoxAsan.dir, ['firefox'], [], ['firefox']),
downloadURLs: this._downloadURLs(firefoxAsan),
browserVersion: firefoxAsan.browserVersion,
_install: () => this._downloadExecutable(firefoxAsan, firefoxAsanExecutable),
@ -603,7 +603,7 @@ export class Registry {
executablePath: () => firefoxBetaExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('firefox-beta', firefoxBetaExecutable, firefoxBeta.installByDefault, sdkLanguage),
installType: firefoxBeta.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'firefox', firefoxBeta.dir, ['firefox'], [], ['firefox']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'firefox', firefoxBeta.dir, ['firefox'], [], ['firefox']),
downloadURLs: this._downloadURLs(firefoxBeta),
browserVersion: firefoxBeta.browserVersion,
_install: () => this._downloadExecutable(firefoxBeta, firefoxBetaExecutable),
@ -631,7 +631,7 @@ export class Registry {
executablePath: () => webkitExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('webkit', webkitExecutable, webkit.installByDefault, sdkLanguage),
installType: webkit.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'webkit', webkit.dir, webkitLinuxLddDirectories, ['libGLESv2.so.2', 'libx264.so'], ['']),
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'webkit', webkit.dir, webkitLinuxLddDirectories, ['libGLESv2.so.2', 'libx264.so'], ['']),
downloadURLs: this._downloadURLs(webkit),
browserVersion: webkit.browserVersion,
_install: () => this._downloadExecutable(webkit, webkitExecutable),
@ -649,7 +649,7 @@ export class Registry {
executablePath: () => ffmpegExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('ffmpeg', ffmpegExecutable, ffmpeg.installByDefault, sdkLanguage),
installType: ffmpeg.installByDefault ? 'download-by-default' : 'download-on-demand',
validateHostRequirements: () => Promise.resolve(),
_validateHostRequirements: () => Promise.resolve(),
downloadURLs: this._downloadURLs(ffmpeg),
_install: () => this._downloadExecutable(ffmpeg, ffmpegExecutable),
_dependencyGroup: 'tools',
@ -664,7 +664,7 @@ export class Registry {
executablePath: () => undefined,
executablePathOrDie: () => '',
installType: 'download-on-demand',
validateHostRequirements: () => Promise.resolve(),
_validateHostRequirements: () => Promise.resolve(),
downloadURLs: this._downloadURLs(android),
_install: () => this._downloadExecutable(android),
_dependencyGroup: 'tools',
@ -704,7 +704,7 @@ export class Registry {
executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false),
executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!,
installType: install ? 'install-script' : 'none',
validateHostRequirements: () => Promise.resolve(),
_validateHostRequirements: () => Promise.resolve(),
_isHermeticInstallation: false,
_install: install,
};
@ -735,14 +735,6 @@ export class Registry {
}
private async _validateHostRequirements(sdkLanguage: string, browserName: BrowserName, browserDirectory: string, linuxLddDirectories: string[], dlOpenLibraries: string[], windowsExeAndDllDirectories: string[]) {
if (getAsBooleanFromENV('PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS')) {
process.stderr.write('Skipping host requirements validation logic because `PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS` env variable is set.\n');
return;
}
const distributionInfo = await getLinuxDistributionInfo();
if (browserName === 'firefox' && distributionInfo?.id === 'ubuntu' && distributionInfo?.version === '16.04')
throw new Error(`Cannot launch Firefox on Ubuntu 16.04! Minimum required Ubuntu version for Firefox browser is 20.04`);
if (os.platform() === 'linux')
return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries);
if (os.platform() === 'win32' && os.arch() === 'x64')
@ -859,6 +851,37 @@ export class Registry {
};
}
async validateHostRequirementsForExecutablesIfNeeded(executables: Executable[], sdkLanguage: string) {
if (getAsBooleanFromENV('PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS')) {
process.stderr.write('Skipping host requirements validation logic because `PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS` env variable is set.\n');
return;
}
for (const executable of executables)
await this._validateHostRequirementsForExecutableIfNeeded(executable, sdkLanguage);
}
private async _validateHostRequirementsForExecutableIfNeeded(executable: Executable, sdkLanguage: string) {
const kMaximumReValidationPeriod = 30 * 24 * 60 * 60 * 1000; // 30 days
// Executable does not require validation.
if (!executable.directory)
return;
const markerFile = path.join(executable.directory, 'DEPENDENCIES_VALIDATED');
// Executable is already validated.
if (await fs.promises.stat(markerFile).then(stat => (Date.now() - stat.mtime.getTime()) < kMaximumReValidationPeriod).catch(() => false))
return;
debugLogger.log('install', `validating host requirements for "${executable.name}"`);
try {
await executable._validateHostRequirements(sdkLanguage);
debugLogger.log('install', `validation passed for ${executable.name}`);
} catch (error) {
debugLogger.log('install', `validation failed for ${executable.name}`);
throw error;
}
await fs.promises.writeFile(markerFile, '').catch(() => {});
}
private _downloadURLs(descriptor: BrowsersJSONDescriptor): string[] {
const paths = (DOWNLOAD_PATHS as any)[descriptor.name];
const downloadPathTemplate: string|undefined = paths[hostPlatform] || paths['<unknown>'];

View File

@ -1,8 +0,0 @@
const playwright = require('playwright');
process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = 1;
(async () => {
const browser = await playwright.chromium.launch();
await browser.close();
})();

View File

@ -15,17 +15,96 @@
*/
import { test, expect } from './npmTest';
test('validate dependencies', async ({ exec }) => {
await exec('npm i playwright');
await exec('npx playwright install chromium');
test.use({ isolateBrowsers: true });
await test.step('default (on)', async () => {
const result1 = await exec('node validate-dependencies.js');
expect(result1).toContain(`PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS`);
test('should validate dependencies correctly if skipped during install', async ({ exec, writeFiles }) => {
await exec('npm i playwright');
await writeFiles({
'test.js': `const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.close();
await browser.close();
})();`,
});
await test.step('disabled (off)', async () => {
const result = await exec('npx playwright install chromium', {
env: {
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: '1',
DEBUG: 'pw:install',
}
});
expect(result).toContain(`Skipping host requirements validation logic because`);
await test.step('should skip dependency validation for a custom executablePath', async () => {
const result2 = await exec('node validate-dependencies-skip-executable-path.js');
expect(result2).not.toContain(`PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS`);
});
await test.step('should skip dependency validation on launch if env var is passed', async () => {
const result = await exec('node test.js', {
env: {
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: '1',
}
});
expect(result).toContain(`Skipping host requirements validation logic because`);
});
await test.step('should validate dependencies (skipped during install)', async () => {
const result = await exec('node test.js', {
env: {
DEBUG: 'pw:install',
},
});
expect(result).toContain(`validating host requirements for "chromium"`);
expect(result).not.toContain(`validating host requirements for "firefox"`);
expect(result).not.toContain(`validating host requirements for "webkit"`);
});
await test.step('should validate dependencies for a second launch (skipped during install)', async () => {
const result = await exec('node test.js', {
env: {
DEBUG: 'pw:install',
},
});
expect(result).toContain(`validating host requirements for "chromium"`);
expect(result).not.toContain(`validating host requirements for "firefox"`);
expect(result).not.toContain(`validating host requirements for "webkit"`);
});
});
test('should not validate dependencies on launch if validated during install', async ({ exec, writeFiles }) => {
await exec('npm i playwright');
await writeFiles({
'test.js': `const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.close();
await browser.close();
})();`,
});
const result = await exec('npx playwright install chromium', {
env: {
DEBUG: 'pw:install',
}
});
expect(result).toContain(`validating host requirements for "chromium"`);
expect(result).not.toContain(`validating host requirements for "firefox"`);
expect(result).not.toContain(`validating host requirements for "webkit"`);
await test.step('should not validate dependencies on launch if already validated', async () => {
const result = await exec('node test.js', {
env: {
DEBUG: 'pw:install',
},
});
expect(result).not.toContain(`validating host requirements for "chromium"`);
expect(result).not.toContain(`validating host requirements for "firefox"`);
expect(result).not.toContain(`validating host requirements for "webkit"`);
});
});