diff --git a/bin/playwright-install b/bin/playwright-install index 3419d63..aeebe0b 100755 --- a/bin/playwright-install +++ b/bin/playwright-install @@ -65,7 +65,7 @@ final class PlaywrightServerInstaller /** * @param list $browserTargets */ - public function __construct(bool $verbose, bool $dryRun, bool $installBrowsers, bool $withDeps, array $browserTargets) + public function __construct(bool $verbose, bool $dryRun, bool $installBrowsers, bool $withDeps, array $browserTargets, private ?string $packageManagerBinary = null) { $this->serverDir = __DIR__; $this->output = new ConsoleOutput(); @@ -137,33 +137,16 @@ final class PlaywrightServerInstaller $this->output->writeln("✓ Node.js $nodeVersion found"); + $packageManager = $this->detectPackageManager(); + try { - $npmProcess = $this->runCommand(['npm', '--version']); + $packageManagerProcess = $this->runCommand([$packageManager, '--version'], $this->serverDir); } catch (ProcessFailedException $exception) { - throw new \RuntimeException('npm is required but not found in PATH.', 0, $exception); + throw new \RuntimeException("$packageManager is required but not found in PATH.", 0, $exception); } - $npmVersion = trim($npmProcess->getOutput()); - $this->output->writeln("✓ npm $npmVersion found"); - - $this->checkPackageManager(); - } - - private function checkPackageManager(): void - { - if ($this->isCommandAvailable('pnpm')) { - $this->output->writeln('✓ pnpm detected - using for faster installs', OutputInterface::VERBOSITY_VERBOSE); - - return; - } - - if ($this->isCommandAvailable('yarn')) { - $this->output->writeln('✓ yarn detected', OutputInterface::VERBOSITY_VERBOSE); - - return; - } - - $this->output->writeln('✓ Using npm for package installation', OutputInterface::VERBOSITY_VERBOSE); + $packageManagerVersion = trim($packageManagerProcess->getOutput()); + $this->output->writeln("✓ $packageManager $packageManagerVersion found"); } private function ensureServerDirectory(): void @@ -222,15 +205,34 @@ final class PlaywrightServerInstaller return true; } } + return false; } private function detectPackageManager(): string + { + return $this->getExplicitPackageManager() ?? $this->getDeclaredPackageManager() ?? $this->detectAvailablePackageManager(); + } + + private function getExplicitPackageManager(): ?string + { + if (null === $this->packageManagerBinary) { + return null; + } + + $name = basename(str_replace('\\', '/', $this->packageManagerBinary)); + if (!preg_match('/^(npm|yarn|pnpm)(?:\.(?:cmd|exe|bat))?$/D', $name, $matches)) { + throw new \RuntimeException('The package-manager binary must be named npm, yarn, or pnpm.'); + } + + return $matches[1]; + } + + private function detectAvailablePackageManager(): string { $pnpmLock = is_file($this->serverDir.'/pnpm-lock.yaml'); $yarnLock = is_file($this->serverDir.'/yarn.lock'); - $pnpmAvailable = $this->isCommandAvailable('pnpm'); $yarnAvailable = $this->isCommandAvailable('yarn'); @@ -261,6 +263,26 @@ final class PlaywrightServerInstaller return 'npm'; } + private function getDeclaredPackageManager(): ?string + { + $packageJsonPath = getcwd().'/package.json'; + + if (!is_file($packageJsonPath)) { + return null; + } + + $packageJson = json_decode((string) file_get_contents($packageJsonPath), true); + $packageManager = is_array($packageJson) ? ($packageJson['packageManager'] ?? null) : null; + + if (!is_string($packageManager)) { + return null; + } + + $packageManager = explode('@', $packageManager, 2)[0]; + + return in_array($packageManager, ['npm', 'yarn', 'pnpm'], true) ? $packageManager : null; + } + private function getInstallCommand(string $packageManager): array { $hasPnpmLock = is_file($this->serverDir.'/pnpm-lock.yaml'); @@ -337,7 +359,7 @@ final class PlaywrightServerInstaller */ private function runCommand(array $command, ?string $cwd = null, ?int $timeout = 60, bool $allowDryRun = false): Process { - $process = new Process($command, $cwd); + $process = new Process($this->getExecutableCommand($command), $cwd); $process->setTimeout($timeout); $this->output->writeln(sprintf('Running command: %s', $process->getCommandLine()), OutputInterface::VERBOSITY_VERBOSE); @@ -363,6 +385,42 @@ final class PlaywrightServerInstaller return $process; } + private function getExecutableCommand(array $command): array + { + if (null === $this->packageManagerBinary) { + return $command; + } + + $packageManager = $this->getExplicitPackageManager(); + if ('npm' === $packageManager && 'npx' === $command[0]) { + $command = array_merge(['npm', 'exec', '--'], array_slice($command, 1)); + } + + if ($command[0] !== $packageManager) { + return $command; + } + + $command[0] = $this->resolvePackageManagerBinary($this->packageManagerBinary); + + return $command; + } + + private function resolvePackageManagerBinary(string $binary): string + { + if (!str_contains($binary, '/') && !str_contains($binary, '\\')) { + return $binary; + } + + // Resolve relative directories before changing cwd, preserving the executable's symlink name. + $directory = realpath(dirname($binary)); + $binary = false === $directory ? $binary : $directory.DIRECTORY_SEPARATOR.basename($binary); + if (false === $directory || !is_file($binary) || !is_executable($binary)) { + throw new \RuntimeException('The package-manager binary was not found or is not executable: '.$this->packageManagerBinary); + } + + return $binary; + } + private function isCommandAvailable(string $command): bool { if (array_key_exists($command, $this->commandAvailability)) { @@ -386,6 +444,7 @@ $withDeps = false; $help = false; $browserTargets = []; $defaultBrowsersRequested = false; +$packageManagerBinary = null; $supportedBrowserTargets = [ 'chromium', 'firefox', @@ -396,7 +455,19 @@ $supportedBrowserTargets = [ 'msedge-beta', ]; -foreach ($args as $arg) { +for ($index = 0; $index < count($args); ++$index) { + $arg = $args[$index]; + $option = explode('=', $arg, 2); + if ('--package-manager-bin' === $option[0]) { + $packageManagerBinary = $option[1] ?? ($args[++$index] ?? ''); + if ('' === $packageManagerBinary || str_starts_with($packageManagerBinary, '-')) { + fwrite(STDERR, "The --package-manager-bin option requires a value.\n"); + exit(2); + } + + continue; + } + switch ($arg) { case '-v': case '--verbose': @@ -456,6 +527,7 @@ if ($help) { echo " --dry-run Print commands without executing install steps\n"; echo " --browsers Install Playwright's default browsers\n"; echo " --with-deps Install selected browsers and system dependencies\n"; + echo " --package-manager-bin=PATH Use a specific npm, yarn, or pnpm executable\n"; echo "\n"; echo "Managed browser targets:\n"; echo " chromium Playwright's bundled Chromium browser\n"; @@ -475,8 +547,10 @@ if ($help) { echo " - Node.js 20+ (for optimal performance and security)\n"; echo " - npm, yarn, or pnpm\n"; echo "\n"; - echo "This script automatically detects and uses the fastest available package manager.\n"; - echo "Preference order: pnpm > yarn > npm\n"; + echo "An explicit package-manager binary overrides automatic selection.\n"; + echo "Otherwise, this script selects the package-manager name declared by the current project.\n"; + echo "It uses the selected executable and does not install or enforce the declared version.\n"; + echo "Without a supported declaration, detection uses server lockfiles and available executables.\n"; echo "\n"; echo "Environment:\n"; echo " PLAYWRIGHT_BROWSERS_PATH Custom directory for Playwright browser binaries\n"; @@ -486,8 +560,9 @@ if ($help) { echo " playwright-install firefox\n"; echo " playwright-install chromium webkit\n"; echo " playwright-install chrome\n"; + echo " playwright-install --package-manager-bin=/path/to/npm --browsers\n"; exit(0); } -$installer = new PlaywrightServerInstaller($verbose, $dryRun, $installBrowsers, $withDeps, $browserTargets); +$installer = new PlaywrightServerInstaller($verbose, $dryRun, $installBrowsers, $withDeps, $browserTargets, $packageManagerBinary); exit($installer->install()); diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 2583217..6f15064 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -58,6 +58,25 @@ PLAYWRIGHT_BROWSERS_PATH=/path/to/.playwright-browsers vendor/bin/playwright-ins For browser targets, branded Chrome and Edge channels, and runtime browser types, see [Browsers, Browser Types, and Channels](./browsers.md). +### Selecting a Package-Manager Binary + +To override automatic package-manager selection, pass the executable to the installer: + +```bash +vendor/bin/playwright-install --package-manager-bin=/path/to/npm --browsers +``` + +The filename identifies the manager: `npm`, `pnpm`, or `yarn` (Windows `.cmd`, `.exe`, and `.bat` extensions are also +accepted). Relative paths are resolved from your current directory. Quote paths containing spaces. + +The selected executable is used for version checks, dependency installation, and browser installation. The usual +installer checks, browser arguments, and options such as `--with-deps` still apply. Without this option, the existing +auto-detection remains in place. + +The override uses the existing installation commands: npm 7+ (for `npm exec`), pnpm, or Yarn Classic (1.x). +It does not add support for modern Yarn, install package-manager versions, or enforce the version in `packageManager`. +The selected executable can still enforce its own project configuration. + ## Your First Script You're now ready to write your first script. Create a new file named `example.php` and add the following code: diff --git a/tests/Integration/Installer/PlaywrightInstallCliTest.php b/tests/Integration/Installer/PlaywrightInstallCliTest.php index 9b617d3..0149dfd 100644 --- a/tests/Integration/Installer/PlaywrightInstallCliTest.php +++ b/tests/Integration/Installer/PlaywrightInstallCliTest.php @@ -15,6 +15,7 @@ namespace Playwright\Tests\Integration\Installer; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -22,10 +23,17 @@ #[CoversNothing] final class PlaywrightInstallCliTest extends TestCase { + /** + * @var array + */ + private array $explicitExecutables = []; + + private bool $useSymlinks = false; + #[Test] public function itForwardsSelectedManagedBrowserTargetsToPlaywright(): void { - $process = $this->runInstaller('--dry-run', '--verbose', 'firefox'); + $process = $this->runInstaller(['--dry-run', '--verbose', 'firefox']); $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); $this->assertStringContainsString("'playwright' 'install' 'firefox'", $process->getOutput()); @@ -34,7 +42,7 @@ public function itForwardsSelectedManagedBrowserTargetsToPlaywright(): void #[Test] public function itForwardsBrowserTargetsAfterTheSystemDependenciesOption(): void { - $process = $this->runInstaller('--dry-run', '--verbose', '--with-deps', 'chromium', 'firefox'); + $process = $this->runInstaller(['--dry-run', '--verbose', '--with-deps', 'chromium', 'firefox']); $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); $this->assertStringContainsString("'playwright' 'install' '--with-deps' 'chromium' 'firefox'", $process->getOutput()); @@ -43,17 +51,17 @@ public function itForwardsBrowserTargetsAfterTheSystemDependenciesOption(): void #[Test] public function itKeepsTheDefaultBrowserInstallShortcut(): void { - $process = $this->runInstaller('--dry-run', '--verbose', '--browsers'); + $process = $this->runInstaller(['--dry-run', '--verbose', '--browsers']); $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - $this->assertStringContainsString("'playwright' 'install'", $process->getOutput()); + $this->assertStringContainsString("'yarn' 'playwright' 'install'", $process->getOutput()); $this->assertStringNotContainsString("'playwright' 'install' 'firefox'", $process->getOutput()); } #[Test] public function itForwardsBrandedBrowserTargetsWithoutTreatingThemAsAliases(): void { - $process = $this->runInstaller('--dry-run', '--verbose', 'chrome', 'msedge-beta'); + $process = $this->runInstaller(['--dry-run', '--verbose', 'chrome', 'msedge-beta']); $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); $this->assertStringContainsString("'playwright' 'install' 'chrome' 'msedge-beta'", $process->getOutput()); @@ -66,7 +74,7 @@ public function itForwardsBrandedBrowserTargetsWithoutTreatingThemAsAliases(): v #[Test] public function itRejectsUnknownBrowserTargetsWithoutNormalizingAliases(): void { - $process = $this->runInstaller('safari'); + $process = $this->runInstaller(['safari']); $this->assertSame(2, $process->getExitCode()); $this->assertSame('', $process->getOutput()); @@ -79,7 +87,7 @@ public function itRejectsUnknownBrowserTargetsWithoutNormalizingAliases(): void #[Test] public function itRejectsTheDefaultShortcutCombinedWithExplicitTargets(): void { - $process = $this->runInstaller('--browsers', 'chromium'); + $process = $this->runInstaller(['--browsers', 'chromium']); $this->assertSame(2, $process->getExitCode()); $this->assertSame('', $process->getOutput()); @@ -92,7 +100,7 @@ public function itRejectsTheDefaultShortcutCombinedWithExplicitTargets(): void #[Test] public function itListsTheSupportedBrowserTargetsInHelp(): void { - $process = $this->runInstaller('--help'); + $process = $this->runInstaller(['--help']); $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); $this->assertStringContainsString('Managed browser targets:', $process->getOutput()); @@ -104,15 +112,153 @@ public function itListsTheSupportedBrowserTargetsInHelp(): void $this->assertStringContainsString('msedge-beta Microsoft Edge Beta', $process->getOutput()); } - private function runInstaller(string ...$arguments): Process + #[Test] + public function itHonorsNpmDeclaredByTheParentProjectWhenYarnIsAvailable(): void + { + $process = $this->runInstaller(['--dry-run', '--verbose', '--browsers'], 'npm@11.6.0'); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertStringContainsString("'npm' '--version'", $process->getOutput()); + $this->assertStringContainsString("'npx' 'playwright' 'install'", $process->getOutput()); + $this->assertStringNotContainsString("'yarn'", $process->getOutput()); + } + + #[Test] + #[DataProvider('explicitPackageManagers')] + public function itUsesTheExplicitBinaryForEveryPackageManagerCommand(string $manager, string $browserCommand): void + { + $versions = ['npm' => '10.0.0', 'pnpm' => '10.0.0', 'yarn' => '1.22.22']; + $this->explicitExecutables = [$manager => $versions[$manager]]; + $process = $this->runInstaller([ + '--verbose', '--browsers', '--package-manager-bin=tools with spaces/'.$manager, + ], 'npm@11.6.0'); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $prefix = "/tools with spaces/$manager'"; + $this->assertStringContainsString($prefix." '--version'", $process->getOutput()); + $this->assertStringContainsString($prefix." 'install'", $process->getOutput()); + $this->assertStringContainsString($prefix.' '.$browserCommand, $process->getOutput()); + $this->assertStringNotContainsString("'npx'", $process->getOutput()); + } + + public static function explicitPackageManagers(): iterable + { + yield 'npm' => ['npm', "'exec' '--' 'playwright' 'install'"]; + yield 'pnpm' => ['pnpm', "'exec' 'playwright' 'install'"]; + yield 'yarn' => ['yarn', "'playwright' 'install'"]; + } + + #[Test] + public function itPreservesTheExplicitExecutableSymlink(): void + { + $this->explicitExecutables = ['pnpm' => '10.0.0']; + $this->useSymlinks = true; + $process = $this->runInstaller([ + '--dry-run', '--browsers', '--package-manager-bin', 'tools with spaces/pnpm', + ]); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertStringContainsString("/tools with spaces/pnpm' 'exec' 'playwright' 'install'", $process->getOutput()); + $this->assertStringNotContainsString('pnpm-target', $process->getOutput()); + } + + #[Test] + #[DataProvider('invalidBinaries')] + public function itRejectsInvalidExplicitBinaries(string $binary, string $message): void + { + $process = $this->runInstaller(['--dry-run', '--package-manager-bin='.$binary]); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString($message, $process->getOutput()); + $this->assertStringNotContainsString('[dry-run] Would execute:', $process->getOutput()); + } + + public static function invalidBinaries(): iterable + { + yield 'missing binary' => ['missing/npm', 'was not found or is not executable']; + yield 'unknown manager' => ['tools/custom-manager', 'must be named npm, yarn, or pnpm']; + } + + #[Test] + public function itRequiresAValueForTheBinaryOption(): void + { + $process = $this->runInstaller(['--package-manager-bin']); + + $this->assertSame(2, $process->getExitCode()); + $this->assertSame("The --package-manager-bin option requires a value.\n", $process->getErrorOutput()); + } + + /** + * @param list $arguments + */ + private function runInstaller(array $arguments, ?string $declaration = null): Process { + $projectDirectory = sys_get_temp_dir().'/playwright-install-'.bin2hex(random_bytes(8)); + $this->createProject($projectDirectory, $declaration); + $this->createExecutables($projectDirectory.'/bin', ['node' => 'v20.0.0', 'npm' => '11.6.0', 'yarn' => '1.22.22']); + $this->createExecutables($projectDirectory.'/tools with spaces', $this->explicitExecutables); + $process = new Process([ \PHP_BINARY, - dirname(__DIR__, 3).'/bin/playwright-install', + $projectDirectory.'/server/playwright-install', ...$arguments, + ], $projectDirectory, [ + 'PATH' => $projectDirectory.'/bin', ]); - $process->run(); + + try { + $process->run(); + } finally { + $this->removeProject($projectDirectory); + } return $process; } + + private function createProject(string $directory, ?string $declaration): void + { + mkdir($directory.'/server', 0777, true); + symlink(dirname(__DIR__, 3).'/vendor', $directory.'/vendor'); + foreach (['playwright-install', 'package.json', 'playwright-server.js'] as $file) { + copy(dirname(__DIR__, 3).'/bin/'.$file, $directory.'/server/'.$file); + } + + if (null !== $declaration) { + file_put_contents($directory.'/package.json', json_encode([ + 'packageManager' => $declaration, + ], JSON_THROW_ON_ERROR)); + } + } + + /** + * @param array $executables + */ + private function createExecutables(string $directory, array $executables): void + { + mkdir($directory); + foreach ($executables as $command => $version) { + $path = $directory.'/'.$command; + $script = "#!/bin/sh\necho '$version'\n"; + if ($this->useSymlinks) { + file_put_contents($path.'-target', $script); + chmod($path.'-target', 0755); + symlink($path.'-target', $path); + } else { + file_put_contents($path, $script); + chmod($path, 0755); + } + } + } + + private function removeProject(string $directory): void + { + foreach (new \FilesystemIterator($directory) as $file) { + if ($file->isDir() && !$file->isLink()) { + $this->removeProject($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + rmdir($directory); + } }