From fa75b3e1a538e7417dd919b91b36de451a8e84b8 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Tue, 4 Aug 2026 23:06:06 -0400 Subject: [PATCH] Report missing dependencies with an actionable message --- src/Browser/BrowserBuilder.php | 12 ++++ src/Exception/MissingDependencyException.php | 70 +++++++++++++++++++ .../JsonRpc/ProcessJsonRpcClient.php | 26 ++++++- src/Transport/ServerFinder.php | 4 +- .../Browser/BrowserBuilderTest.php | 48 +++++++++++++ .../BrowserServerFunctionalTest.php | 3 +- .../MissingDependencyExceptionTest.php | 56 +++++++++++++++ tests/Unit/Transport/ServerFinderTest.php | 31 ++++++++ 8 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 src/Exception/MissingDependencyException.php create mode 100644 tests/Unit/Exception/MissingDependencyExceptionTest.php diff --git a/src/Browser/BrowserBuilder.php b/src/Browser/BrowserBuilder.php index 5084076..c854abb 100644 --- a/src/Browser/BrowserBuilder.php +++ b/src/Browser/BrowserBuilder.php @@ -15,6 +15,7 @@ namespace Playwright\Browser; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Exception\MissingDependencyException; use Playwright\Exception\PlaywrightException; use Playwright\Transport\Sanitizer; use Playwright\Transport\TransportInterface; @@ -117,6 +118,17 @@ public function launch(): BrowserInterface if (!is_string($response['error'])) { throw new PlaywrightException('Browser launch failed with unknown error'); } + + // Playwright makes both checks itself, but points at "npx playwright install" and + // "npx playwright install-deps", which is not how this package installs them. + if (str_contains($response['error'], "Executable doesn't exist")) { + throw MissingDependencyException::browsers($response['error']); + } + + if (str_contains($response['error'], 'Host system is missing dependencies')) { + throw MissingDependencyException::hostLibraries($response['error']); + } + throw new PlaywrightException($response['error']); } diff --git a/src/Exception/MissingDependencyException.php b/src/Exception/MissingDependencyException.php new file mode 100644 index 0000000..539f9c9 --- /dev/null +++ b/src/Exception/MissingDependencyException.php @@ -0,0 +1,70 @@ +process->isRunning()) { $exitCode = $this->process->getExitCode() ?? -1; - throw new DisconnectedException(sprintf('Process exited with code %d', $exitCode), 0, null, ['exitCode' => $exitCode, 'pid' => $this->process->getPid()]); + $stderr = trim($this->processLauncher->getStderrOutput()); + + if (self::indicatesMissingServer($stderr)) { + throw MissingDependencyException::server($stderr); + } + + // Without the stderr excerpt an exit code alone gives the user nothing to act on. + $message = sprintf('Process exited with code %d', $exitCode); + + if ('' !== $stderr) { + $message .= sprintf(":\n\n%s", $stderr); + } + + throw new DisconnectedException($message, 0, null, ['exitCode' => $exitCode, 'pid' => $this->process->getPid(), 'stderr' => $stderr]); } try { @@ -249,4 +263,14 @@ private function ensureProcessRunning(): void throw new DisconnectedException('Process health check failed: '.$e->getMessage(), 0, $e); } } + + /** + * playwright-server.js starts with require('playwright'), so a missing install kills the + * process immediately with a module resolution error. + */ + private static function indicatesMissingServer(string $stderr): bool + { + return str_contains($stderr, "Cannot find module 'playwright'") + || str_contains($stderr, 'Cannot find module "playwright"'); + } } diff --git a/src/Transport/ServerFinder.php b/src/Transport/ServerFinder.php index b1e41cb..cde14dc 100644 --- a/src/Transport/ServerFinder.php +++ b/src/Transport/ServerFinder.php @@ -14,7 +14,7 @@ namespace Playwright\Transport; -use Playwright\Exception\NetworkException; +use Playwright\Exception\MissingDependencyException; use Playwright\Node\NodeBinaryResolver; use Playwright\Node\NodeBinaryResolverInterface; @@ -109,7 +109,7 @@ public function findServer(): array { $playwrightPath = $this->findPlaywright(); if (!$playwrightPath) { - throw new NetworkException('Playwright not found. Please run: npm install playwright'); + throw MissingDependencyException::server(); } $nodeResolver = $this->nodeResolver ?? new NodeBinaryResolver(); diff --git a/tests/Integration/Browser/BrowserBuilderTest.php b/tests/Integration/Browser/BrowserBuilderTest.php index b50c8c7..789b13b 100644 --- a/tests/Integration/Browser/BrowserBuilderTest.php +++ b/tests/Integration/Browser/BrowserBuilderTest.php @@ -20,6 +20,8 @@ use Playwright\Browser\Browser; use Playwright\Browser\BrowserBuilder; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Exception\MissingDependencyException; +use Playwright\Exception\PlaywrightException; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -269,4 +271,50 @@ public function itSendsContextOptionsSoTheDefaultContextRecordsVideo(): void $builder->launch(); } + + #[Test] + public function itReportsMissingBrowsersWithTheInstallCommand(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send')->willReturn([ + 'error' => "browserType.launch: Executable doesn't exist at /tmp/chrome\nPlease run the following command", + ]); + + $builder = new BrowserBuilder('chromium', $transport, new NullLogger(), new PlaywrightConfig()); + + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessageMatches('/vendor\/bin\/playwright-install/'); + + $builder->launch(); + } + + #[Test] + public function itReportsMissingHostDependenciesWithTheWithDepsCommand(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send')->willReturn([ + 'error' => "browserType.launch: Host system is missing dependencies to run browsers.\nMissing libraries: libnss3", + ]); + + $builder = new BrowserBuilder('chromium', $transport, new NullLogger(), new PlaywrightConfig()); + + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessageMatches('/vendor\/bin\/playwright-install --with-deps/'); + + $builder->launch(); + } + + #[Test] + public function itLeavesUnrelatedLaunchErrorsAlone(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send')->willReturn(['error' => 'browserType.launch: something else went wrong']); + + $builder = new BrowserBuilder('chromium', $transport, new NullLogger(), new PlaywrightConfig()); + + $this->expectException(PlaywrightException::class); + $this->expectExceptionMessage('browserType.launch: something else went wrong'); + + $builder->launch(); + } } diff --git a/tests/Integration/BrowserServer/BrowserServerFunctionalTest.php b/tests/Integration/BrowserServer/BrowserServerFunctionalTest.php index 3ab9bb8..8d078f4 100644 --- a/tests/Integration/BrowserServer/BrowserServerFunctionalTest.php +++ b/tests/Integration/BrowserServer/BrowserServerFunctionalTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Playwright\BrowserServer\BrowserServer; +use Playwright\Exception\MissingDependencyException; use Playwright\Exception\ProcessLaunchException; use Playwright\Exception\TransportException; use Playwright\PlaywrightFactory; @@ -43,7 +44,7 @@ public function testLaunchServerChromiumAndClose(): void // Try graceful close $server->close(); - } catch (ProcessLaunchException|TransportException $e) { + } catch (MissingDependencyException|ProcessLaunchException|TransportException $e) { $this->markTestSkipped('launchServer not available or environment missing browsers: '.$e->getMessage()); } catch (\Throwable $e) { $this->fail('Unexpected failure launching browser server: '.$e->getMessage()); diff --git a/tests/Unit/Exception/MissingDependencyExceptionTest.php b/tests/Unit/Exception/MissingDependencyExceptionTest.php new file mode 100644 index 0000000..4cafe09 --- /dev/null +++ b/tests/Unit/Exception/MissingDependencyExceptionTest.php @@ -0,0 +1,56 @@ +assertInstanceOf(RuntimeException::class, $exception); + $this->assertStringContainsString(MissingDependencyException::INSTALL_COMMAND, $exception->getMessage()); + } + + public function testServerDependenciesKeepsTheUnderlyingDetails(): void + { + $exception = MissingDependencyException::server("Error: Cannot find module 'playwright'"); + + $this->assertStringContainsString(MissingDependencyException::INSTALL_COMMAND, $exception->getMessage()); + $this->assertStringContainsString("Cannot find module 'playwright'", $exception->getMessage()); + } + + public function testBrowsersNamesTheInstallCommandAndKeepsTheDetails(): void + { + $exception = MissingDependencyException::browsers("browserType.launch: Executable doesn't exist at /tmp/chrome"); + + $this->assertStringContainsString(MissingDependencyException::INSTALL_COMMAND, $exception->getMessage()); + $this->assertStringContainsString("Executable doesn't exist at /tmp/chrome", $exception->getMessage()); + } + + public function testHostDependenciesNamesTheWithDepsCommandAndKeepsTheDetails(): void + { + $exception = MissingDependencyException::hostLibraries('Host system is missing dependencies to run browsers.'); + + $this->assertStringContainsString(MissingDependencyException::WITH_DEPENDENCIES_COMMAND, $exception->getMessage()); + $this->assertStringContainsString('Host system is missing dependencies', $exception->getMessage()); + } +} diff --git a/tests/Unit/Transport/ServerFinderTest.php b/tests/Unit/Transport/ServerFinderTest.php index 702d10a..1b0c2cf 100644 --- a/tests/Unit/Transport/ServerFinderTest.php +++ b/tests/Unit/Transport/ServerFinderTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Playwright\Exception\MissingDependencyException; use Playwright\Node\NodeBinaryResolverInterface; use Playwright\Transport\ServerFinder; @@ -31,6 +32,36 @@ public function testFindPlaywrightReturnsPathOrNull(): void $this->assertTrue(is_string($result) || null === $result); } + public function testFindServerNamesTheInstallCommandWhenPlaywrightIsMissing(): void + { + $tmpCwd = sys_get_temp_dir().'/pwphp_missing_'.bin2hex(random_bytes(4)); + $originalPath = getenv('PLAYWRIGHT_PATH'); + $originalCwd = getcwd(); + + try { + mkdir($tmpCwd.'/a/b', 0777, true); + chdir($tmpCwd.'/a/b'); + putenv('PLAYWRIGHT_PATH'); + + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessageMatches('/vendor\/bin\/playwright-install/'); + + (new ServerFinder())->findServer(); + } finally { + if (is_string($originalCwd)) { + chdir($originalCwd); + } + + if (false !== $originalPath) { + putenv('PLAYWRIGHT_PATH='.$originalPath); + } + + @rmdir($tmpCwd.'/a/b'); + @rmdir($tmpCwd.'/a'); + @rmdir($tmpCwd); + } + } + public function testFindServerWorksWhenPlaywrightFound(): void { $nodeResolver = $this->createMock(NodeBinaryResolverInterface::class);