├── tests ├── resources │ ├── worker.js │ ├── stylesheet.css │ ├── puphpeteer-logo.png │ ├── denys-barabanov-jKcFmXCfaQ8-unsplash.jpg │ └── index.html ├── UntestableResource.php ├── RiskyResource.php ├── DownloadTest.php ├── TestCase.php ├── ResourceInstantiator.php └── PuphpeteerTest.php ├── .gitignore ├── src ├── Resources │ ├── TimeoutError.php │ ├── CDPSession.php │ ├── Touchscreen.php │ ├── Accessibility.php │ ├── Tracing.php │ ├── FileChooser.php │ ├── Dialog.php │ ├── ConsoleMessage.php │ ├── Coverage.php │ ├── SecurityDetails.php │ ├── Keyboard.php │ ├── WebWorker.php │ ├── Mouse.php │ ├── BrowserFetcher.php │ ├── Target.php │ ├── ExecutionContext.php │ ├── BrowserContext.php │ ├── HTTPResponse.php │ ├── HTTPRequest.php │ ├── EventEmitter.php │ ├── JSHandle.php │ ├── Browser.php │ ├── ElementHandle.php │ ├── Frame.php │ └── Page.php ├── get-puppeteer-version.js ├── PuppeteerProcessDelegate.php ├── Traits │ ├── AliasesSelectionMethods.php │ └── AliasesEvaluationMethods.php ├── Puppeteer.php ├── PuppeteerConnectionDelegate.js ├── Command │ └── GenerateDocumentationCommand.php └── doc-generator.ts ├── bin └── console ├── phpunit.xml ├── .github ├── workflows │ └── tests.yaml ├── ISSUE_TEMPLATE │ └── bug_report.md └── contributing.md ├── package.json ├── LICENSE ├── composer.json ├── CHANGELOG.md └── README.md /tests/resources/worker.js: -------------------------------------------------------------------------------- 1 | // There's nothing to do, just wait. 2 | -------------------------------------------------------------------------------- /tests/resources/stylesheet.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-transform: lowercase; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build/ 2 | /node_modules/ 3 | /vendor/ 4 | .phpunit.result.cache 5 | composer.lock 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /tests/resources/puphpeteer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sokolnikov911/puphpeteer/master/tests/resources/puphpeteer-logo.png -------------------------------------------------------------------------------- /tests/UntestableResource.php: -------------------------------------------------------------------------------- 1 | $options = null) 9 | */ 10 | class Accessibility extends BasicResource 11 | { 12 | // 13 | } 14 | -------------------------------------------------------------------------------- /src/Resources/Tracing.php: -------------------------------------------------------------------------------- 1 | $options = null) 9 | * @method mixed stop() 10 | */ 11 | class Tracing extends BasicResource 12 | { 13 | // 14 | } 15 | -------------------------------------------------------------------------------- /src/get-puppeteer-version.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function output(value) { 4 | process.stdout.write(JSON.stringify(value)); 5 | } 6 | 7 | try { 8 | const manifest = require('puppeteer/package.json'); 9 | output(manifest.version); 10 | } catch (exception) { 11 | output(null); 12 | } 13 | -------------------------------------------------------------------------------- /src/Resources/FileChooser.php: -------------------------------------------------------------------------------- 1 | add(new GenerateDocumentationCommand) 11 | ->getApplication() 12 | ->run(); 13 | -------------------------------------------------------------------------------- /src/Resources/Dialog.php: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | tests 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/Resources/ConsoleMessage.php: -------------------------------------------------------------------------------- 1 | $options = null) 9 | * @method mixed[] stopJSCoverage() 10 | * @method void startCSSCoverage(array $options = null) 11 | * @method mixed[] stopCSSCoverage() 12 | */ 13 | class Coverage extends BasicResource 14 | { 15 | // 16 | } 17 | -------------------------------------------------------------------------------- /src/Resources/SecurityDetails.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Document 5 | 6 | 7 | 8 | 9 | 10 |

Example Page

11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Resources/Keyboard.php: -------------------------------------------------------------------------------- 1 | &array{ delay: float } $options = null) 10 | * @method void down(array $options = null) 11 | * @method void up(array $options = null) 12 | * @method void wheel(array $options = null) 13 | */ 14 | class Mouse extends BasicResource 15 | { 16 | // 17 | } 18 | -------------------------------------------------------------------------------- /src/PuppeteerProcessDelegate.php: -------------------------------------------------------------------------------- 1 | value = $resourceRetriever(); 15 | } catch (NodeFatalException $exception) { 16 | $this->exception = $exception; 17 | } 18 | } 19 | 20 | public function value() { 21 | return $this->value; 22 | } 23 | 24 | public function exception(): ?NodeFatalException { 25 | return $this->exception; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Resources/BrowserContext.php: -------------------------------------------------------------------------------- 1 | querySelectorAll(string $selector) 8 | * @method array querySelectorXPath(string $expression) 9 | */ 10 | trait AliasesSelectionMethods 11 | { 12 | public function querySelector(...$arguments) 13 | { 14 | return $this->__call('$', $arguments); 15 | } 16 | 17 | public function querySelectorAll(...$arguments) 18 | { 19 | return $this->__call('$$', $arguments); 20 | } 21 | 22 | public function querySelectorXPath(...$arguments) 23 | { 24 | return $this->__call('$x', $arguments); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Resources/HTTPResponse.php: -------------------------------------------------------------------------------- 1 | headers() 14 | * @method \Nesk\Puphpeteer\Resources\SecurityDetails|null securityDetails() 15 | * @method mixed buffer() 16 | * @method string text() 17 | * @method mixed json() 18 | * @method \Nesk\Puphpeteer\Resources\HTTPRequest request() 19 | * @method bool fromCache() 20 | * @method bool fromServiceWorker() 21 | * @method \Nesk\Puphpeteer\Resources\Frame|null frame() 22 | */ 23 | class HTTPResponse extends BasicResource 24 | { 25 | // 26 | } 27 | -------------------------------------------------------------------------------- /src/Traits/AliasesEvaluationMethods.php: -------------------------------------------------------------------------------- 1 | __call('$eval', $arguments); 16 | } 17 | 18 | public function querySelectorAllEval(...$arguments) 19 | { 20 | return $this->__call('$$eval', $arguments); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nesk/puphpeteer", 3 | "version": "2.0.0", 4 | "description": "A Puppeteer bridge for PHP, supporting the entire API.", 5 | "keywords": [ 6 | "php", 7 | "puppeteer", 8 | "headless-chrome", 9 | "testing", 10 | "web", 11 | "developer-tools", 12 | "automation" 13 | ], 14 | "author": { 15 | "name": "Johann Pardanaud", 16 | "email": "pardanaud.j@gmail.com", 17 | "url": "https://johann.pardanaud.com/" 18 | }, 19 | "license": "MIT", 20 | "repository": "github:nesk/puphpeteer", 21 | "engines": { 22 | "node": ">=9.0.0" 23 | }, 24 | "dependencies": { 25 | "@nesk/rialto": "^1.2.1", 26 | "puppeteer": "~5.5.0" 27 | }, 28 | "devDependencies": { 29 | "@types/yargs": "^15.0.10", 30 | "typescript": "^4.1.2", 31 | "yargs": "^16.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Resources/HTTPRequest.php: -------------------------------------------------------------------------------- 1 | headers() 13 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null response() 14 | * @method \Nesk\Puphpeteer\Resources\Frame|null frame() 15 | * @method bool isNavigationRequest() 16 | * @method \Nesk\Puphpeteer\Resources\HTTPRequest[] redirectChain() 17 | * @method array{ errorText: string }|null failure() 18 | * @method void continue(mixed $overrides = null) 19 | * @method void respond(mixed $response) 20 | * @method void abort(mixed $errorCode = null) 21 | */ 22 | class HTTPRequest extends BasicResource 23 | { 24 | // 25 | } 26 | -------------------------------------------------------------------------------- /src/Resources/EventEmitter.php: -------------------------------------------------------------------------------- 1 | getProperties() 13 | * @method array jsonValue() 14 | * @method \Nesk\Puphpeteer\Resources\ElementHandle|null asElement() 15 | * @method void dispose() 16 | * @method string toString() 17 | */ 18 | class JSHandle extends BasicResource 19 | { 20 | // 21 | } 22 | -------------------------------------------------------------------------------- /src/Resources/Browser.php: -------------------------------------------------------------------------------- 1 | $options = null) 15 | * @method \Nesk\Puphpeteer\Resources\Page[] pages() 16 | * @method string version() 17 | * @method string userAgent() 18 | * @method void close() 19 | * @method void disconnect() 20 | * @method bool isConnected() 21 | */ 22 | class Browser extends EventEmitter 23 | { 24 | // 25 | } 26 | -------------------------------------------------------------------------------- /src/Resources/ElementHandle.php: -------------------------------------------------------------------------------- 1 | |null asElement() 10 | * @method \Nesk\Puphpeteer\Resources\Frame|null contentFrame() 11 | * @method void hover() 12 | * @method void click(array $options = null) 13 | * @method string[] select(string ...$values) 14 | * @method void uploadFile(string ...$filePaths) 15 | * @method void tap() 16 | * @method void focus() 17 | * @method void type(string $text, array{ delay: float } $options = null) 18 | * @method void press(mixed $key, array $options = null) 19 | * @method mixed|null boundingBox() 20 | * @method mixed|null boxModel() 21 | * @method string|mixed|null screenshot(array{ } $options = null) 22 | * @method bool isIntersectingViewport() 23 | */ 24 | class ElementHandle extends JSHandle 25 | { 26 | use AliasesSelectionMethods, AliasesEvaluationMethods; 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Johann Pardanaud 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve PuPHPeteer 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **Reproducible example** 11 | ```php 12 | // Provide a full example reproducing the bug 13 | ``` 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Generated logs** 19 | Provide the logs generated by your example, see how to get them in the guidelines for contributing to this repository: 20 | https://github.com/nesk/puphpeteer/blob/master/.github/contributing.md#provide-logs-with-your-bug-report 21 | 22 | ``` 23 | The generated logs 24 | ``` 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Environment (please complete the following information):** 30 | - OS: [e.g. Windows 10, macOS 10.13.2, Ubuntu 18.04] 31 | - Node version: [e.g. 10.4.0] 32 | - PHP version [e.g. 7.2.6] 33 | 34 | My Node package manager is: 35 | - [x] NPM (specify the version) 36 | - [ ] Yarn (specify the version) 37 | - [ ] Other (specify the name and version) 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sokolnikov911/puphpeteer", 3 | "description": "A Puppeteer bridge for PHP, supporting the entire API.", 4 | "keywords": [ 5 | "php", 6 | "puppeteer", 7 | "headless-chrome", 8 | "testing", 9 | "web", 10 | "developer-tools", 11 | "automation" 12 | ], 13 | "type": "library", 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Johann Pardanaud", 18 | "email": "pardanaud.j@gmail.com" 19 | } 20 | ], 21 | "require": { 22 | "php": ">=7.3", 23 | "sokolnikov911/rialto": "1.4.1", 24 | "psr/log": "^1.0|^2.0|^3.0", 25 | "vierbergenlars/php-semver": "^3.0.2" 26 | }, 27 | "require-dev": { 28 | "monolog/monolog": "^2.0", 29 | "phpunit/phpunit": "^9.0", 30 | "symfony/process": "^4.0|^5.0|^6.0", 31 | "symfony/console": "^4.0|^5.0|^6.0" 32 | }, 33 | "repositories": [ 34 | { 35 | "type": "vcs", 36 | "url": "https://github.com/sietzekeuning/rialto" 37 | } 38 | ], 39 | "autoload": { 40 | "psr-4": { 41 | "Nesk\\Puphpeteer\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Nesk\\Puphpeteer\\Tests\\": "tests/" 47 | } 48 | }, 49 | "scripts": { 50 | "post-install-cmd": "npm install", 51 | "test": "./vendor/bin/phpunit" 52 | }, 53 | "config": { 54 | "sort-packages": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/DownloadTest.php: -------------------------------------------------------------------------------- 1 | serveResources(); 18 | 19 | // Launch the browser to run tests on. 20 | $this->launchBrowser(); 21 | } 22 | 23 | /** 24 | * Downloads an image and checks string length. 25 | * 26 | * @test 27 | */ 28 | public function download_image() 29 | { 30 | // Download the image 31 | $page = $this->browser 32 | ->newPage() 33 | ->goto($this->url . '/puphpeteer-logo.png'); 34 | 35 | $base64 = $page->buffer()->toString('base64'); 36 | $imageString = base64_decode($base64); 37 | 38 | // Get the reference image from resources 39 | $reference = file_get_contents('tests/resources/puphpeteer-logo.png'); 40 | 41 | $this->assertTrue( 42 | mb_strlen($reference) === mb_strlen($imageString), 43 | 'Image is not the same length after download.' 44 | ); 45 | } 46 | 47 | /** 48 | * Downloads an image and checks string length. 49 | * 50 | * @test 51 | */ 52 | // public function download_large_image() 53 | // { 54 | // // Download the image 55 | // $page = $this->browser 56 | // ->newPage() 57 | // ->goto($this->url . '/denys-barabanov-jKcFmXCfaQ8-unsplash.jpg'); 58 | 59 | // $base64 = $page->buffer()->toString('base64'); 60 | // $imageString = base64_decode($base64); 61 | 62 | // // Get the reference image from resources 63 | // $reference = file_get_contents('tests/resources/denys-barabanov-jKcFmXCfaQ8-unsplash.jpg'); 64 | 65 | // $this->assertTrue( 66 | // mb_strlen($reference) === mb_strlen($imageString), 67 | // 'Large image is not the same length after download.' 68 | // ); 69 | // } 70 | } 71 | -------------------------------------------------------------------------------- /src/Resources/Frame.php: -------------------------------------------------------------------------------- 1 | $options) 23 | * @method \Nesk\Puphpeteer\Resources\ElementHandle addStyleTag(array $options) 24 | * @method void click(string $selector, array{ delay: float, button: mixed, clickCount: float } $options = null) 25 | * @method void focus(string $selector) 26 | * @method void hover(string $selector) 27 | * @method string[] select(string $selector, string ...$values) 28 | * @method void tap(string $selector) 29 | * @method void type(string $selector, string $text, array{ delay: float } $options = null) 30 | * @method \Nesk\Puphpeteer\Resources\JSHandle|null waitFor(string|float|callable $selectorOrFunctionOrTimeout, array $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) 31 | * @method void waitForTimeout(float $milliseconds) 32 | * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForSelector(string $selector, array $options = null) 33 | * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForXPath(string $xpath, array $options = null) 34 | * @method \Nesk\Puphpeteer\Resources\JSHandle waitForFunction(callable|string $pageFunction, array $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) 35 | * @method string title() 36 | */ 37 | class Frame extends BasicResource 38 | { 39 | use AliasesSelectionMethods, AliasesEvaluationMethods; 40 | } 41 | -------------------------------------------------------------------------------- /src/Puppeteer.php: -------------------------------------------------------------------------------- 1 | $options) 14 | * @method void registerCustomQueryHandler(string $name, mixed $queryHandler) 15 | * @method void unregisterCustomQueryHandler(string $name) 16 | * @method string[] customQueryHandlerNames() 17 | * @method void clearCustomQueryHandlers() 18 | */ 19 | class Puppeteer extends AbstractEntryPoint 20 | { 21 | /** 22 | * Default options. 23 | * 24 | * @var array 25 | */ 26 | protected $options = [ 27 | 'read_timeout' => 30, 28 | 29 | // Logs the output of Browser's console methods (console.log, console.debug, etc...) to the PHP logger 30 | 'log_browser_console' => false, 31 | ]; 32 | 33 | /** 34 | * Instantiate Puppeteer's entry point. 35 | */ 36 | public function __construct(array $userOptions = []) 37 | { 38 | if (!empty($userOptions['logger']) && $userOptions['logger'] instanceof LoggerInterface) { 39 | $this->checkPuppeteerVersion($userOptions['executable_path'] ?? 'node', $userOptions['logger']); 40 | } 41 | 42 | parent::__construct( 43 | __DIR__.'/PuppeteerConnectionDelegate.js', 44 | new PuppeteerProcessDelegate, 45 | $this->options, 46 | $userOptions 47 | ); 48 | } 49 | 50 | private function checkPuppeteerVersion(string $nodePath, LoggerInterface $logger): void { 51 | $currentVersion = $this->currentPuppeteerVersion($nodePath); 52 | $acceptedVersions = $this->acceptedPuppeteerVersion(); 53 | 54 | try { 55 | $semver = new version($currentVersion); 56 | $expression = new expression($acceptedVersions); 57 | 58 | if (!$semver->satisfies($expression)) { 59 | $logger->warning( 60 | "The installed version of Puppeteer (v$currentVersion) doesn't match the requirements" 61 | ." ($acceptedVersions), you may encounter issues." 62 | ); 63 | } 64 | } catch (SemVerException $exception) { 65 | $logger->warning("Puppeteer doesn't seem to be installed."); 66 | } 67 | } 68 | 69 | private function currentPuppeteerVersion(string $nodePath): ?string { 70 | $process = new Process([$nodePath, __DIR__.'/get-puppeteer-version.js']); 71 | $process->mustRun(); 72 | 73 | return json_decode($process->getOutput()); 74 | } 75 | 76 | private function acceptedPuppeteerVersion(): string { 77 | $npmManifestPath = __DIR__.'/../package.json'; 78 | $npmManifest = json_decode(file_get_contents($npmManifestPath)); 79 | 80 | return $npmManifest->dependencies->puppeteer; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | getName())[0] ?? ''; 23 | $testMethod = new \ReflectionMethod($this, $methodName); 24 | $docComment = $testMethod->getDocComment(); 25 | 26 | if (preg_match('/@dontPopulateProperties (.*)/', $docComment, $matches)) { 27 | $this->dontPopulateProperties = array_values(array_filter(explode(' ', $matches[1]))); 28 | } 29 | } 30 | 31 | /** 32 | * Stops the browser and local server 33 | */ 34 | public function tearDown(): void 35 | { 36 | // Close the browser. 37 | if (isset($this->browser)) { 38 | $this->browser->close(); 39 | } 40 | 41 | // Shutdown the local server 42 | if (isset($this->servingProcess)) { 43 | $this->servingProcess->stop(0); 44 | } 45 | } 46 | 47 | /** 48 | * Serves the resources folder locally on port 8089 49 | */ 50 | protected function serveResources(): void 51 | { 52 | // Spin up a local server to deliver the resources. 53 | $this->host = '127.0.0.1:8089'; 54 | $this->url = "http://{$this->host}"; 55 | $this->serverDir = __DIR__.'/resources'; 56 | 57 | $this->servingProcess = new Process(['php', '-S', $this->host, '-t', $this->serverDir]); 58 | $this->servingProcess->start(); 59 | } 60 | 61 | /** 62 | * Launches the PuPHPeteer-controlled browser 63 | */ 64 | protected function launchBrowser(): void 65 | { 66 | /** 67 | * Chrome doesn't support Linux sandbox on many CI environments 68 | * 69 | * @see: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-fails-due-to-sandbox-issues 70 | */ 71 | $this->browserOptions = [ 72 | 'args' => ['--no-sandbox', '--disable-setuid-sandbox'], 73 | ]; 74 | 75 | if ($this->canPopulateProperty('browser')) { 76 | $this->browser = (new Puppeteer)->launch($this->browserOptions); 77 | } 78 | } 79 | 80 | public function canPopulateProperty(string $propertyName): bool 81 | { 82 | return !in_array($propertyName, $this->dontPopulateProperties); 83 | } 84 | 85 | public function isLogLevel(): Callback { 86 | $psrLogLevels = (new ReflectionClass(LogLevel::class))->getConstants(); 87 | $monologLevels = (new ReflectionClass(Logger::class))->getConstants(); 88 | $monologLevels = array_intersect_key($monologLevels, $psrLogLevels); 89 | 90 | return $this->callback(function ($level) use ($psrLogLevels, $monologLevels) { 91 | if (is_string($level)) { 92 | return in_array($level, $psrLogLevels, true); 93 | } else if (is_int($level)) { 94 | return in_array($level, $monologLevels, true); 95 | } 96 | 97 | return false; 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | **Note:** PuPHPeteer is heavily based on [Rialto](https://github.com/nesk/rialto). For a complete overview of the changes, you might want to check out [Rialto's changelog](https://github.com/nesk/rialto/blob/master/CHANGELOG.md) too. 9 | 10 | ## [Unreleased] 11 | _In progress…_ 12 | 13 | ## [2.0.0] - 2020-12-01 14 | ### Added 15 | - Support Puppeteer v5.5 16 | - Support PHP 8 17 | - Add documentation on all resources to provide autocompletion in IDEs 18 | 19 | ### Removed 20 | - Drop support for PHP 7.1 and 7.2 21 | 22 | ## [1.6.0] - 2019-07-01 23 | ### Added 24 | - Support Puppeteer v1.18 25 | 26 | ## [1.5.0] - 2019-03-17 27 | ### Added 28 | - Support Puppeteer v1.13 29 | - Make the `ElementHandle` resource extend the `JSHandle` one 30 | 31 | ### Fixed 32 | - Add missing `Accessibility` resource 33 | 34 | ## [1.4.1] - 2018-11-27 35 | ### Added 36 | - Support Puppeteer v1.10 37 | 38 | ## [1.4.0] - 2018-09-22 39 | ### Added 40 | - Support Puppeteer v1.8 41 | 42 | ### Changed 43 | - Detect resource types by using the constructor name 44 | 45 | ### Fixed 46 | - Logs of initial pages are now retrieved 47 | 48 | ## [1.3.0] - 2018-08-20 49 | ### Added 50 | - Add a `log_browser_console` option to log the output of Browser's console methods (`console.log`, `console.debug`, `console.table`, etc…) to the PHP logger 51 | - Support Puppeteer v1.7 52 | 53 | ## [1.2.0] - 2018-07-25 54 | ### Added 55 | - Support Puppeteer v1.6 56 | 57 | ### Changed 58 | - Upgrade to Rialto v1.1 59 | 60 | ## [1.1.0] - 2018-06-12 61 | ### Added 62 | - Support Puppeteer v1.5 63 | - Add aliases for evaluation methods to the `ElementHandle` resource 64 | - Support new Puppeteer v1.5 resources: 65 | - `BrowserContext` 66 | - `Worker` 67 | 68 | ### Fixed 69 | - Fix Travis tests 70 | 71 | ## [1.0.0] - 2018-06-05 72 | ### Changed 73 | - Change PHP's vendor name from `extractr-io` to `nesk` 74 | - Change NPM's scope name from `@extractr-io` to `@nesk` 75 | - Upgrade to Rialto v1 76 | 77 | ## [0.2.2] - 2018-04-20 78 | ### Added 79 | - Support Puppeteer v1.3 80 | - Test missing Puppeteer resources: `ConsoleMessage` and `Dialog` 81 | - Show a warning in logs if Puppeteer's version doesn't match requirements 82 | 83 | ## [0.2.1] - 2018-04-09 84 | ### Changed 85 | - Update Rialto version requirements 86 | 87 | ## [0.2.0] - 2018-02-19 88 | ### Added 89 | - Support new Puppeteer v1.1 resources: 90 | - `BrowserFetcher` 91 | - `CDPSession` 92 | - `Coverage` 93 | - `SecurityDetails` 94 | - Test Puppeteer resources 95 | - Support PHPUnit v7 96 | - Add Travis integration 97 | 98 | ### Changed 99 | - Lock Puppeteer's version to v1.1 to avoid issues with forward compatibility 100 | 101 | ## 0.1.0 - 2018-01-29 102 | First release 103 | 104 | 105 | [Unreleased]: https://github.com/nesk/puphpeteer/compare/2.0.0...HEAD 106 | [2.0.0]: https://github.com/nesk/puphpeteer/compare/1.6.0...2.0.0 107 | [1.6.0]: https://github.com/nesk/puphpeteer/compare/1.5.0...1.6.0 108 | [1.5.0]: https://github.com/nesk/puphpeteer/compare/1.4.1...1.5.0 109 | [1.4.1]: https://github.com/nesk/puphpeteer/compare/1.4.0...1.4.1 110 | [1.4.0]: https://github.com/nesk/puphpeteer/compare/1.3.0...1.4.0 111 | [1.3.0]: https://github.com/nesk/puphpeteer/compare/1.2.0...1.3.0 112 | [1.2.0]: https://github.com/nesk/puphpeteer/compare/1.1.0...1.2.0 113 | [1.1.0]: https://github.com/nesk/puphpeteer/compare/1.0.0...1.1.0 114 | [1.0.0]: https://github.com/nesk/puphpeteer/compare/0.2.2...1.0.0 115 | [0.2.2]: https://github.com/nesk/puphpeteer/compare/0.2.1...0.2.2 116 | [0.2.1]: https://github.com/nesk/puphpeteer/compare/0.2.0...0.2.1 117 | [0.2.0]: https://github.com/nesk/puphpeteer/compare/0.1.0...0.2.0 118 | -------------------------------------------------------------------------------- /src/PuppeteerConnectionDelegate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const puppeteer = require('puppeteer'), 4 | {ConnectionDelegate} = require('@nesk/rialto'), 5 | Logger = require('@nesk/rialto/src/node-process/Logger'), 6 | ConsoleInterceptor = require('@nesk/rialto/src/node-process/NodeInterceptors/ConsoleInterceptor'), 7 | StandardStreamsInterceptor = require('@nesk/rialto/src/node-process/NodeInterceptors/StandardStreamsInterceptor'); 8 | 9 | /** 10 | * Handle the requests of a connection to control Puppeteer. 11 | */ 12 | class PuppeteerConnectionDelegate extends ConnectionDelegate 13 | { 14 | /** 15 | * Constructor. 16 | * 17 | * @param {Object} options 18 | */ 19 | constructor(options) { 20 | super(options); 21 | 22 | this.browsers = new Set; 23 | 24 | this.addSignalEventListeners(); 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | async handleInstruction(instruction, responseHandler, errorHandler) { 31 | instruction.setDefaultResource(puppeteer); 32 | 33 | let value = null; 34 | 35 | try { 36 | value = await instruction.execute(); 37 | } catch (error) { 38 | if (instruction.shouldCatchErrors()) { 39 | return errorHandler(error); 40 | } 41 | 42 | throw error; 43 | } 44 | 45 | if (this.isInstanceOf(value, 'Browser')) { 46 | this.browsers.add(value); 47 | 48 | if (this.options.log_browser_console === true) { 49 | const initialPages = await value.pages() 50 | initialPages.forEach(page => page.on('console', this.logConsoleMessage)); 51 | } 52 | } 53 | 54 | if (this.options.log_browser_console === true && this.isInstanceOf(value, 'Page')) { 55 | value.on('console', this.logConsoleMessage); 56 | } 57 | 58 | responseHandler(value); 59 | } 60 | 61 | /** 62 | * Checks if a value is an instance of a class. The check must be done with the `[object].constructor.name` 63 | * property because relying on Puppeteer's constructors isn't viable since the exports aren't constrained by semver. 64 | * 65 | * @protected 66 | * @param {*} value 67 | * @param {string} className 68 | * 69 | * @see {@link https://github.com/GoogleChrome/puppeteer/issues/3067|Puppeteer's issue about semver on exports} 70 | */ 71 | isInstanceOf(value, className) { 72 | const nonObjectValues = [undefined, null]; 73 | 74 | return !nonObjectValues.includes(value) 75 | && !nonObjectValues.includes(value.constructor) 76 | && value.constructor.name === className; 77 | } 78 | 79 | /** 80 | * Log the console message. 81 | * 82 | * @param {ConsoleMessage} consoleMessage 83 | */ 84 | async logConsoleMessage(consoleMessage) { 85 | const type = consoleMessage.type(); 86 | 87 | if (!ConsoleInterceptor.typeIsSupported(type)) { 88 | return; 89 | } 90 | 91 | const level = ConsoleInterceptor.getLevelFromType(type); 92 | const args = await Promise.all(consoleMessage.args().map(arg => arg.jsonValue())); 93 | 94 | StandardStreamsInterceptor.startInterceptingStrings(message => { 95 | Logger.log('Browser', level, ConsoleInterceptor.formatMessage(message)); 96 | }); 97 | 98 | ConsoleInterceptor.originalConsole[type](...args); 99 | 100 | StandardStreamsInterceptor.stopInterceptingStrings(); 101 | } 102 | 103 | /** 104 | * Listen for process signal events. 105 | * 106 | * @protected 107 | */ 108 | addSignalEventListeners() { 109 | for (let eventName of ['SIGINT', 'SIGTERM', 'SIGHUP']) { 110 | process.on(eventName, () => { 111 | this.closeAllBrowsers(); 112 | process.exit(); 113 | }); 114 | } 115 | } 116 | 117 | /** 118 | * Close all the browser instances when the process exits. 119 | * 120 | * Calling this method before exiting Node is mandatory since Puppeteer doesn't seem to handle that properly. 121 | * 122 | * @protected 123 | */ 124 | closeAllBrowsers() { 125 | for (let browser of this.browsers.values()) { 126 | browser.close(); 127 | } 128 | } 129 | } 130 | 131 | module.exports = PuppeteerConnectionDelegate; 132 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to PuPHPeteer 2 | 3 | Please, be sure to read this document _before_ creating an issue or a pull-request. 4 | 5 | ## Where to ask a question or report a bug 6 | 7 | First, be sure to differentiate the original project [Puppeteer ![#40b5a4](https://placehold.it/10/40b5a4/000000?text=+)](https://github.com/GoogleChrome/puppeteer), developed by Google to interact with Chromium, from [PuPHPeteer ![#8846F0](https://placehold.it/10/8846F0/000000?text=+)](https://github.com/nesk/puphpeteer), an unofficial PHP bridge to call Puppeteer methods in PHP. 8 | 9 | Here are some cases to help you choosing where to ask a question or report a bug. The first list item matching your case should be used: 10 | 11 | 1. You don't know how to do _[something]_ with **Puppeteer ![#40b5a4](https://placehold.it/10/40b5a4/000000?text=+)**: ask your question [on StackOverflow with the _puppeteer_ tag](https://stackoverflow.com/questions/tagged/puppeteer?sort=newest). 12 | 2. You don't know how to do _[something]_ with **PuPHPeteer ![#8846F0](https://placehold.it/10/8846F0/000000?text=+)**: ask your question [in PuPHPeteer's issues](https://github.com/nesk/puphpeteer/issues). 13 | 3. You encountered a bug: 14 | 1. It is reproducible with **Puppeteer ![#40b5a4](https://placehold.it/10/40b5a4/000000?text=+)**: report the bug [in Puppeteer's issues](https://github.com/GoogleChrome/puppeteer/issues). 15 | 2. it is reproducible only with **PuPHPeteer ![#8846F0](https://placehold.it/10/8846F0/000000?text=+)**: report the bug [in PuPHPeteer's issues](https://github.com/nesk/puphpeteer/issues). 16 | 17 | ## Provide logs with your bug report 18 | 19 | Bug reports should contain logs generated by your reproducible example. To get them you must provide a logger to your PuPHPeteer instance. Say you have the following code in your bug report: 20 | 21 | ```php 22 | use Nesk\Puphpeteer\Puppeteer; 23 | 24 | $puppeteer = new Puppeteer; 25 | $browser = $puppeteer->launch(); 26 | $browser->newPage()->goto('https://example.com'); 27 | $browser->close(); 28 | ``` 29 | 30 | Require Monolog with Composer: 31 | 32 | ```shell 33 | composer require monolog/monolog 34 | ``` 35 | 36 | And provide a Monolog instance to the `Puppeteer` constructor: 37 | 38 | ```diff 39 | use Nesk\Puphpeteer\Puppeteer; 40 | 41 | - $puppeteer = new Puppeteer; 42 | + $logPath = 'path/to/your.log'; 43 | + 44 | + $logger = new \Monolog\Logger('PuPHPeteer'); 45 | + $logger->pushHandler(new \Monolog\Handler\StreamHandler($logPath, \Monolog\Logger::DEBUG)); 46 | + 47 | + $puppeteer = new Puppeteer([ 48 | + 'logger' => $logger, 49 | + 'log_node_console' => true, 50 | + 'log_browser_console' => true, 51 | + ]); 52 | + 53 | $browser = $puppeteer->launch(); 54 | $browser->newPage()->goto('https://example.com'); 55 | $browser->close(); 56 | ``` 57 | 58 | Execute your code and `path/to/your.log` will contain the generated logs, here's an example of what you can get and provide in your bug report: 59 | 60 | ``` 61 | [2018-08-17 10:26:01] PuPHPeteer.INFO: Applying options... {"options":{"read_timeout":30,"log_browser_console":true,"logger":"[object] (Monolog\\Logger: {})","log_node_console":true}} [] 62 | [2018-08-17 10:26:01] PuPHPeteer.DEBUG: Options applied and merged with defaults {"options":{"executable_path":"node","idle_timeout":60,"read_timeout":30,"stop_timeout":3,"logger":"[object] (Monolog\\Logger: {})","log_node_console":true,"debug":false,"log_browser_console":true}} [] 63 | [2018-08-17 10:26:01] PuPHPeteer.INFO: Starting process with command line: 'node' '/Users/johann/Development/puphpeteer/node_modules/@nesk/rialto/src/node-process/serve.js' '/Users/johann/Development/puphpeteer/src/PuppeteerConnectionDelegate.js' '{"idle_timeout":60,"log_node_console":true,"log_browser_console":true}' {"commandline":"'node' '/Users/johann/Development/puphpeteer/node_modules/@nesk/rialto/src/node-process/serve.js' '/Users/johann/Development/puphpeteer/src/PuppeteerConnectionDelegate.js' '{\"idle_timeout\":60,\"log_node_console\":true,\"log_browser_console\":true}'"} [] 64 | [2018-08-17 10:26:01] PuPHPeteer.INFO: Process started with PID 18153 {"pid":18153} [] 65 | [2018-08-17 10:26:01] PuPHPeteer.DEBUG: Sending an instruction to the port 59621... {"pid":18153,"port":59621,"instruction":{"type":"call","name":"launch","catched":false,"value":[]}} [] 66 | [2018-08-17 10:26:01] PuPHPeteer.DEBUG: Received data from the port 59621... {"pid":18153,"port":59621,"data":"[object] (Nesk\\Puphpeteer\\Resources\\Browser: {\"__rialto_resource__\":true,\"class_name\":\"Browser\",\"id\":\"1534501561533.8093\"})"} [] 67 | ``` 68 | -------------------------------------------------------------------------------- /tests/ResourceInstantiator.php: -------------------------------------------------------------------------------- 1 | browserOptions = $browserOptions; 13 | $this->url = $url; 14 | 15 | $this->resources = [ 16 | 'Accessibility' => function ($puppeteer) { 17 | return $this->Page($puppeteer)->accessibility; 18 | }, 19 | 'Browser' => function ($puppeteer) { 20 | return $puppeteer->launch($this->browserOptions); 21 | }, 22 | 'BrowserContext' => function ($puppeteer) { 23 | return $this->Browser($puppeteer)->createIncognitoBrowserContext(); 24 | }, 25 | 'BrowserFetcher' => function ($puppeteer) { 26 | return $puppeteer->createBrowserFetcher(); 27 | }, 28 | 'CDPSession' => function ($puppeteer) { 29 | return $this->Target($puppeteer)->createCDPSession(); 30 | }, 31 | 'ConsoleMessage' => function () { 32 | return new UntestableResource; 33 | }, 34 | 'Coverage' => function ($puppeteer) { 35 | return $this->Page($puppeteer)->coverage; 36 | }, 37 | 'Dialog' => function () { 38 | return new UntestableResource; 39 | }, 40 | 'ElementHandle' => function ($puppeteer) { 41 | return $this->Page($puppeteer)->querySelector('body'); 42 | }, 43 | 'EventEmitter' => function ($puppeteer) { 44 | return $puppeteer->launch($this->browserOptions); 45 | }, 46 | 'ExecutionContext' => function ($puppeteer) { 47 | return $this->Frame($puppeteer)->executionContext(); 48 | }, 49 | 'FileChooser' => function () { 50 | return new UntestableResource; 51 | }, 52 | 'Frame' => function ($puppeteer) { 53 | return $this->Page($puppeteer)->mainFrame(); 54 | }, 55 | 'HTTPRequest' => function ($puppeteer) { 56 | return $this->HTTPResponse($puppeteer)->request(); 57 | }, 58 | 'HTTPResponse' => function ($puppeteer) { 59 | return $this->Page($puppeteer)->goto($this->url); 60 | }, 61 | 'JSHandle' => function ($puppeteer) { 62 | return $this->Page($puppeteer)->evaluateHandle(JsFunction::createWithBody('window')); 63 | }, 64 | 'Keyboard' => function ($puppeteer) { 65 | return $this->Page($puppeteer)->keyboard; 66 | }, 67 | 'Mouse' => function ($puppeteer) { 68 | return $this->Page($puppeteer)->mouse; 69 | }, 70 | 'Page' => function ($puppeteer) { 71 | return $this->Browser($puppeteer)->newPage(); 72 | }, 73 | 'SecurityDetails' => function ($puppeteer) { 74 | return new RiskyResource(function () use ($puppeteer) { 75 | return $this->Page($puppeteer)->goto('https://example.com')->securityDetails(); 76 | }); 77 | }, 78 | 'Target' => function ($puppeteer) { 79 | return $this->Page($puppeteer)->target(); 80 | }, 81 | 'TimeoutError' => function () { 82 | return new UntestableResource; 83 | }, 84 | 'Touchscreen' => function ($puppeteer) { 85 | return $this->Page($puppeteer)->touchscreen; 86 | }, 87 | 'Tracing' => function ($puppeteer) { 88 | return $this->Page($puppeteer)->tracing; 89 | }, 90 | 'WebWorker' => function ($puppeteer) { 91 | $page = $this->Page($puppeteer); 92 | $page->goto($this->url, ['waitUntil' => 'networkidle0']); 93 | return $page->workers()[0]; 94 | }, 95 | ]; 96 | } 97 | 98 | public function getResourceNames(): array 99 | { 100 | return array_keys($this->resources); 101 | } 102 | 103 | public function __call(string $name, array $arguments) 104 | { 105 | if (!isset($this->resources[$name])) { 106 | throw new \InvalidArgumentException("The $name resource is not supported."); 107 | } 108 | 109 | return $this->resources[$name](...$arguments); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Resources/Page.php: -------------------------------------------------------------------------------- 1 | $options = null) 17 | * @method void setGeolocation(array $options) 18 | * @method \Nesk\Puphpeteer\Resources\Target target() 19 | * @method \Nesk\Puphpeteer\Resources\Browser browser() 20 | * @method \Nesk\Puphpeteer\Resources\BrowserContext browserContext() 21 | * @method \Nesk\Puphpeteer\Resources\Frame mainFrame() 22 | * @method \Nesk\Puphpeteer\Resources\Frame[] frames() 23 | * @method \Nesk\Puphpeteer\Resources\WebWorker[] workers() 24 | * @method void setRequestInterception(bool $value) 25 | * @method void setOfflineMode(bool $enabled) 26 | * @method void setDefaultNavigationTimeout(float $timeout) 27 | * @method void setDefaultTimeout(float $timeout) 28 | * @method mixed evaluateHandle(callable|string $pageFunction, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) 29 | * @method \Nesk\Puphpeteer\Resources\JSHandle queryObjects(\Nesk\Puphpeteer\Resources\JSHandle $prototypeHandle) 30 | * @method mixed[] cookies(string ...$urls) 31 | * @method void deleteCookie(mixed ...$cookies) 32 | * @method void setCookie(mixed ...$cookies) 33 | * @method \Nesk\Puphpeteer\Resources\ElementHandle addScriptTag(array{ url: string, path: string, content: string, type: string } $options) 34 | * @method \Nesk\Puphpeteer\Resources\ElementHandle addStyleTag(array{ url: string, path: string, content: string } $options) 35 | * @method void exposeFunction(string $name, callable $puppeteerFunction) 36 | * @method void authenticate(mixed $credentials) 37 | * @method void setExtraHTTPHeaders(array $headers) 38 | * @method void setUserAgent(string $userAgent) 39 | * @method mixed metrics() 40 | * @method string url() 41 | * @method string content() 42 | * @method void setContent(string $html, array $options = null) 43 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse goto(string $url, array&array{ referer: string } $options = null) 44 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null reload(array $options = null) 45 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null waitForNavigation(array $options = null) 46 | * @method \Nesk\Puphpeteer\Resources\HTTPRequest waitForRequest(string|callable $urlOrPredicate, array{ timeout: float } $options = null) 47 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse waitForResponse(string|callable $urlOrPredicate, array{ timeout: float } $options = null) 48 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null goBack(array $options = null) 49 | * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null goForward(array $options = null) 50 | * @method void bringToFront() 51 | * @method void emulate(array{ viewport: mixed, userAgent: string } $options) 52 | * @method void setJavaScriptEnabled(bool $enabled) 53 | * @method void setBypassCSP(bool $enabled) 54 | * @method void emulateMediaType(string $type = null) 55 | * @method void emulateMediaFeatures(mixed[] $features = null) 56 | * @method void emulateTimezone(string $timezoneId = null) 57 | * @method void emulateIdleState(array{ isUserActive: bool, isScreenUnlocked: bool } $overrides = null) 58 | * @method void emulateVisionDeficiency(mixed $type = null) 59 | * @method void setViewport(mixed $viewport) 60 | * @method mixed|null viewport() 61 | * @method mixed evaluate(mixed $pageFunction, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) 62 | * @method void evaluateOnNewDocument(callable|string $pageFunction, mixed ...$args) 63 | * @method void setCacheEnabled(bool $enabled = null) 64 | * @method mixed|string|null screenshot(array $options = null) 65 | * @method mixed pdf(array $options = null) 66 | * @method string title() 67 | * @method void close(array{ runBeforeUnload: bool } $options = null) 68 | * @method bool isClosed() 69 | * @method void click(string $selector, array{ delay: float, button: mixed, clickCount: float } $options = null) 70 | * @method void focus(string $selector) 71 | * @method void hover(string $selector) 72 | * @method string[] select(string $selector, string ...$values) 73 | * @method void tap(string $selector) 74 | * @method void type(string $selector, string $text, array{ delay: float } $options = null) 75 | * @method \Nesk\Puphpeteer\Resources\JSHandle waitFor(string|float|callable $selectorOrFunctionOrTimeout, array{ visible: bool, hidden: bool, timeout: float, polling: string|float } $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) 76 | * @method void waitForTimeout(float $milliseconds) 77 | * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForSelector(string $selector, array{ visible: bool, hidden: bool, timeout: float } $options = null) 78 | * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForXPath(string $xpath, array{ visible: bool, hidden: bool, timeout: float } $options = null) 79 | * @method \Nesk\Puphpeteer\Resources\JSHandle waitForFunction(callable|string $pageFunction, array{ timeout: float, polling: string|float } $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) 80 | */ 81 | class Page extends EventEmitter 82 | { 83 | use AliasesSelectionMethods, AliasesEvaluationMethods; 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PuPHPeteer 2 | 3 | 4 | 5 | [![PHP Version](https://img.shields.io/packagist/php-v/nesk/puphpeteer.svg?style=flat-square)](http://php.net/) 6 | [![Composer Version](https://img.shields.io/packagist/v/nesk/puphpeteer.svg?style=flat-square&label=Composer)](https://packagist.org/packages/nesk/puphpeteer) 7 | [![Node Version](https://img.shields.io/node/v/@nesk/puphpeteer.svg?style=flat-square&label=Node)](https://nodejs.org/) 8 | [![NPM Version](https://img.shields.io/npm/v/@nesk/puphpeteer.svg?style=flat-square&label=NPM)](https://www.npmjs.com/package/@nesk/puphpeteer) 9 | [![Build Status](https://img.shields.io/travis/nesk/puphpeteer.svg?style=flat-square&label=Build%20Status)](https://travis-ci.org/nesk/puphpeteer) 10 | 11 | A [Puppeteer](https://github.com/GoogleChrome/puppeteer/) bridge for PHP, supporting the entire API. Based on [Rialto](https://github.com/nesk/rialto/), a package to manage Node resources from PHP. 12 | 13 | Here are some examples [borrowed from Puppeteer's documentation](https://github.com/GoogleChrome/puppeteer/blob/master/README.md#usage) and adapted to PHP's syntax: 14 | 15 | **Example** - navigating to https://example.com and saving a screenshot as *example.png*: 16 | 17 | ```php 18 | use Nesk\Puphpeteer\Puppeteer; 19 | 20 | $puppeteer = new Puppeteer; 21 | $browser = $puppeteer->launch(); 22 | 23 | $page = $browser->newPage(); 24 | $page->goto('https://example.com'); 25 | $page->screenshot(['path' => 'example.png']); 26 | 27 | $browser->close(); 28 | ``` 29 | 30 | **Example** - evaluate a script in the context of the page: 31 | 32 | ```php 33 | use Nesk\Puphpeteer\Puppeteer; 34 | use Nesk\Rialto\Data\JsFunction; 35 | 36 | $puppeteer = new Puppeteer; 37 | 38 | $browser = $puppeteer->launch(); 39 | $page = $browser->newPage(); 40 | $page->goto('https://example.com'); 41 | 42 | // Get the "viewport" of the page, as reported by the page. 43 | $dimensions = $page->evaluate(JsFunction::createWithBody(" 44 | return { 45 | width: document.documentElement.clientWidth, 46 | height: document.documentElement.clientHeight, 47 | deviceScaleFactor: window.devicePixelRatio 48 | }; 49 | ")); 50 | 51 | printf('Dimensions: %s', print_r($dimensions, true)); 52 | 53 | $browser->close(); 54 | ``` 55 | 56 | ## Requirements and installation 57 | 58 | This package requires PHP >= 7.3 and Node >= 8. 59 | 60 | Install it with these two command lines: 61 | 62 | ```shell 63 | composer require nesk/puphpeteer 64 | npm install @nesk/puphpeteer 65 | ``` 66 | 67 | ## Notable differences between PuPHPeteer and Puppeteer 68 | 69 | ### Puppeteer's class must be instantiated 70 | 71 | Instead of requiring Puppeteer: 72 | 73 | ```js 74 | const puppeteer = require('puppeteer'); 75 | ``` 76 | 77 | You have to instantiate the `Puppeteer` class: 78 | 79 | ```php 80 | $puppeteer = new Puppeteer; 81 | ``` 82 | 83 | This will create a new Node process controlled by PHP. 84 | 85 | You can also pass some options to the constructor, see [Rialto's documentation](https://github.com/nesk/rialto/blob/master/docs/api.md#options). PuPHPeteer also extends these options: 86 | 87 | ```php 88 | [ 89 | // Logs the output of Browser's console methods (console.log, console.debug, etc...) to the PHP logger 90 | 'log_browser_console' => false, 91 | ] 92 | ``` 93 | 94 |
95 | ⏱ Want to use some timeouts higher than 30 seconds in Puppeteer's API?
96 | 97 | If you use some timeouts higher than 30 seconds, you will have to set a higher value for the `read_timeout` option (default: `35`): 98 | 99 | ```php 100 | $puppeteer = new Puppeteer([ 101 | 'read_timeout' => 65, // In seconds 102 | ]); 103 | 104 | $puppeteer->launch()->newPage()->goto($url, [ 105 | 'timeout' => 60000, // In milliseconds 106 | ]); 107 | ``` 108 |
109 | 110 | ### No need to use the `await` keyword 111 | 112 | With PuPHPeteer, every method call or property getting/setting is synchronous. 113 | 114 | ### Some methods have been aliased 115 | 116 | The following methods have been aliased because PHP doesn't support the `$` character in method names: 117 | 118 | - `$` => `querySelector` 119 | - `$$` => `querySelectorAll` 120 | - `$x` => `querySelectorXPath` 121 | - `$eval` => `querySelectorEval` 122 | - `$$eval` => `querySelectorAllEval` 123 | 124 | Use these aliases just like you would have used the original methods: 125 | 126 | ```php 127 | $divs = $page->querySelectorAll('div'); 128 | ``` 129 | 130 | ### Evaluated functions must be created with `JsFunction` 131 | 132 | Functions evaluated in the context of the page must be written [with the `JsFunction` class](https://github.com/nesk/rialto/blob/master/docs/api.md#javascript-functions), the body of these functions must be written in JavaScript instead of PHP. 133 | 134 | ```php 135 | use Nesk\Rialto\Data\JsFunction; 136 | 137 | $pageFunction = JsFunction::createWithParameters(['element']) 138 | ->body("return element.textContent"); 139 | ``` 140 | 141 | ### Exceptions must be caught with `->tryCatch` 142 | 143 | If an error occurs in Node, a `Node\FatalException` will be thrown and the process closed, you will have to create a new instance of `Puppeteer`. 144 | 145 | To avoid that, you can ask Node to catch these errors by prepending your instruction with `->tryCatch`: 146 | 147 | ```php 148 | use Nesk\Rialto\Exceptions\Node; 149 | 150 | try { 151 | $page->tryCatch->goto('invalid_url'); 152 | } catch (Node\Exception $exception) { 153 | // Handle the exception... 154 | } 155 | ``` 156 | 157 | Instead, a `Node\Exception` will be thrown, the Node process will stay alive and usable. 158 | 159 | ## License 160 | 161 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 162 | 163 | ## Logo attribution 164 | 165 | PuPHPeteer's logo is composed of: 166 | 167 | - [Puppet](https://thenounproject.com/search/?q=puppet&i=52120) by Luis Prado from [the Noun Project](http://thenounproject.com/). 168 | - [Elephant](https://thenounproject.com/search/?q=elephant&i=954119) by Lluisa Iborra from [the Noun Project](http://thenounproject.com/). 169 | 170 | Thanks to [Laravel News](https://laravel-news.com/) for picking the icons and colors of the logo. 171 | -------------------------------------------------------------------------------- /src/Command/GenerateDocumentationCommand.php: -------------------------------------------------------------------------------- 1 | addOption( 26 | 'puppeteerPath', 27 | null, 28 | InputOption::VALUE_OPTIONAL, 29 | 'The path where Puppeteer is installed.', 30 | self::NODE_MODULES_DIR.'/puppeteer' 31 | ); 32 | } 33 | 34 | /** 35 | * Builds the documentation generator from TypeScript to JavaScript. 36 | */ 37 | private static function buildDocumentationGenerator(): void 38 | { 39 | $process = new Process([ 40 | self::NODE_MODULES_DIR.'/.bin/tsc', 41 | '--outDir', 42 | self::BUILD_DIR, 43 | __DIR__.'/../../src/'.self::DOC_FILE_NAME.'.ts', 44 | ]); 45 | $process->run(); 46 | } 47 | 48 | /** 49 | * Gets the documentation from the TypeScript documentation generator. 50 | */ 51 | private static function getDocumentation(string $puppeteerPath, array $resourceNames): array 52 | { 53 | self::buildDocumentationGenerator(); 54 | 55 | $commonFiles = \glob("$puppeteerPath/lib/esm/puppeteer/common/*.d.ts"); 56 | $nodeFiles = \glob("$puppeteerPath/lib/esm/puppeteer/node/*.d.ts"); 57 | 58 | $process = new Process( 59 | \array_merge( 60 | ['node', self::BUILD_DIR.'/'.self::DOC_FILE_NAME.'.js', 'php'], 61 | $commonFiles, 62 | $nodeFiles, 63 | ['--resources-namespace', self::RESOURCES_NAMESPACE, '--resources'], 64 | $resourceNames 65 | ) 66 | ); 67 | $process->mustRun(); 68 | 69 | return \json_decode($process->getOutput(), true); 70 | } 71 | 72 | private static function getResourceNames(): array 73 | { 74 | return array_map(function (string $filePath): string { 75 | return explode('.', \basename($filePath))[0]; 76 | }, \glob(self::RESOURCES_DIR.'/*')); 77 | } 78 | 79 | private static function generatePhpDocWithDocumentation(array $classDocumentation): ?string 80 | { 81 | $properties = array_map(function (string $property): string { 82 | return "\n * @property $property"; 83 | }, $classDocumentation['properties']); 84 | $properties = \implode('', $properties); 85 | 86 | $getters = array_map(function (string $getter): string { 87 | return "\n * @property-read $getter"; 88 | }, $classDocumentation['getters']); 89 | $getters = \implode('', $getters); 90 | 91 | $methods = array_map(function (string $method): string { 92 | return "\n * @method $method"; 93 | }, $classDocumentation['methods']); 94 | $methods = \implode('', $methods); 95 | 96 | if (\strlen($properties) > 0 || \strlen($getters) > 0 || \strlen($methods) > 0) { 97 | return "/**$properties$getters$methods\n */"; 98 | } 99 | 100 | return null; 101 | } 102 | 103 | /** 104 | * Writes the doc comment in the PHP class. 105 | */ 106 | private static function writePhpDoc(string $className, string $phpDoc): void 107 | { 108 | $reflectionClass = new \ReflectionClass($className); 109 | 110 | if (! $reflectionClass) { 111 | return; 112 | } 113 | 114 | $fileName = $reflectionClass->getFileName(); 115 | 116 | $contents = file_get_contents($fileName); 117 | 118 | // If there already is a doc comment, replace it. 119 | if ($doc = $reflectionClass->getDocComment()) { 120 | $newContents = str_replace($doc, $phpDoc, $contents); 121 | } else { 122 | $startLine = $reflectionClass->getStartLine(); 123 | 124 | $lines = explode("\n", $contents); 125 | 126 | $before = array_slice($lines, 0, $startLine - 1); 127 | $after = array_slice($lines, $startLine - 1); 128 | 129 | $newContents = implode("\n", array_merge($before, explode("\n", $phpDoc), $after)); 130 | } 131 | 132 | file_put_contents($fileName, $newContents); 133 | } 134 | 135 | /** 136 | * Executes the current command. 137 | */ 138 | protected function execute(InputInterface $input, OutputInterface $output): int 139 | { 140 | $io = new SymfonyStyle($input, $output); 141 | 142 | $resourceNames = self::getResourceNames(); 143 | $documentation = self::getDocumentation($input->getOption('puppeteerPath'), $resourceNames); 144 | 145 | foreach ($resourceNames as $resourceName) { 146 | $classDocumentation = $documentation[$resourceName] ?? null; 147 | 148 | if ($classDocumentation !== null) { 149 | $phpDoc = self::generatePhpDocWithDocumentation($classDocumentation); 150 | if ($phpDoc !== null) { 151 | $resourceClass = self::RESOURCES_NAMESPACE.'\\'.$resourceName; 152 | self::writePhpDoc($resourceClass, $phpDoc); 153 | } 154 | } 155 | } 156 | 157 | // Handle the specific Puppeteer class 158 | $classDocumentation = $documentation['Puppeteer'] ?? null; 159 | if ($classDocumentation !== null) { 160 | $phpDoc = self::generatePhpDocWithDocumentation($classDocumentation); 161 | if ($phpDoc !== null) { 162 | self::writePhpDoc(Puppeteer::class, $phpDoc); 163 | } 164 | } 165 | 166 | $missingResources = \array_diff(\array_keys($documentation), $resourceNames); 167 | foreach ($missingResources as $resource) { 168 | $io->warning("The $resource class in Puppeteer doesn't have any equivalent in PuPHPeteer."); 169 | } 170 | 171 | $inexistantResources = \array_diff($resourceNames, \array_keys($documentation)); 172 | foreach ($inexistantResources as $resource) { 173 | $io->error("The $resource resource doesn't have any equivalent in Puppeteer."); 174 | } 175 | 176 | return 0; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /tests/PuphpeteerTest.php: -------------------------------------------------------------------------------- 1 | serveResources(); 19 | 20 | // Launch the browser to run tests on. 21 | $this->launchBrowser(); 22 | } 23 | 24 | /** @test */ 25 | public function can_browse_website() 26 | { 27 | $response = $this->browser->newPage()->goto($this->url); 28 | 29 | $this->assertTrue($response->ok(), 'Failed asserting that the response is successful.'); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function can_use_method_aliases() 36 | { 37 | $page = $this->browser->newPage(); 38 | 39 | $page->goto($this->url); 40 | 41 | $select = function($resource) { 42 | $elements = [ 43 | $resource->querySelector('h1'), 44 | $resource->querySelectorAll('h1')[0], 45 | $resource->querySelectorXPath('/html/body/h1')[0], 46 | ]; 47 | 48 | $this->assertContainsOnlyInstancesOf(ElementHandle::class, $elements); 49 | }; 50 | 51 | $evaluate = function($resource) { 52 | $strings = [ 53 | $resource->querySelectorEval('h1', JsFunction::createWithBody('return "Hello World!";')), 54 | $resource->querySelectorAllEval('h1', JsFunction::createWithBody('return "Hello World!";')), 55 | ]; 56 | 57 | foreach ($strings as $string) { 58 | $this->assertEquals('Hello World!', $string); 59 | } 60 | }; 61 | 62 | // Test method aliases for Page, Frame and ElementHandle classes 63 | $resources = [$page, $page->mainFrame(), $page->querySelector('body')]; 64 | foreach ($resources as $resource) { 65 | $select($resource); 66 | $evaluate($resource); 67 | } 68 | } 69 | 70 | /** @test */ 71 | public function can_evaluate_a_selection() 72 | { 73 | $page = $this->browser->newPage(); 74 | 75 | $page->goto($this->url); 76 | 77 | $title = $page->querySelectorEval('h1', JsFunction::createWithParameters(['node']) 78 | ->body('return node.textContent;')); 79 | 80 | $titleCount = $page->querySelectorAllEval('h1', JsFunction::createWithParameters(['nodes']) 81 | ->body('return nodes.length;')); 82 | 83 | $this->assertEquals('Example Page', $title); 84 | $this->assertEquals(1, $titleCount); 85 | } 86 | 87 | /** @test */ 88 | public function can_intercept_requests() 89 | { 90 | $page = $this->browser->newPage(); 91 | 92 | $page->setRequestInterception(true); 93 | 94 | $page->on('request', JsFunction::createWithParameters(['request']) 95 | ->body('request.resourceType() === "stylesheet" ? request.abort() : request.continue()')); 96 | 97 | $page->goto($this->url); 98 | 99 | $backgroundColor = $page->querySelectorEval('h1', JsFunction::createWithParameters(['node']) 100 | ->body('return getComputedStyle(node).textTransform')); 101 | 102 | $this->assertNotEquals('lowercase', $backgroundColor); 103 | } 104 | 105 | /** 106 | * @test 107 | * @dataProvider resourceProvider 108 | * @dontPopulateProperties browser 109 | */ 110 | public function check_all_resources_are_supported(string $name) 111 | { 112 | $incompleteTest = false; 113 | $resourceInstantiator = new ResourceInstantiator($this->browserOptions, $this->url); 114 | $resource = $resourceInstantiator->{$name}(new Puppeteer, $this->browserOptions); 115 | 116 | if ($resource instanceof UntestableResource) { 117 | $incompleteTest = true; 118 | } else if ($resource instanceof RiskyResource) { 119 | if (!empty($resource->exception())) { 120 | $incompleteTest = true; 121 | } else { 122 | try { 123 | $this->assertInstanceOf("Nesk\\Puphpeteer\\Resources\\$name", $resource->value()); 124 | } catch (ExpectationFailedException $exception) { 125 | $incompleteTest = true; 126 | } 127 | } 128 | } else { 129 | $this->assertInstanceOf("Nesk\\Puphpeteer\\Resources\\$name", $resource); 130 | } 131 | 132 | if (!$incompleteTest) return; 133 | 134 | $reason = "The \"$name\" resource has not been tested properly, probably" 135 | ." for a good reason but you might want to have a look: \n\n "; 136 | 137 | if ($resource instanceof UntestableResource) { 138 | $reason .= "\e[33mMarked as untestable.\e[0m"; 139 | } else { 140 | if (!empty($exception = $resource->exception())) { 141 | $reason .= "\e[31mMarked as risky because of a Node error: {$exception->getMessage()}\e[0m"; 142 | } else { 143 | $value = print_r($resource->value(), true); 144 | $reason .= "\e[31mMarked as risky because of an unexpected value: $value\e[0m"; 145 | } 146 | } 147 | 148 | $this->markTestIncomplete($reason); 149 | } 150 | 151 | public function resourceProvider(): \Generator 152 | { 153 | $resourceNames = (new ResourceInstantiator([], ''))->getResourceNames(); 154 | 155 | foreach ($resourceNames as $name) { 156 | yield [$name]; 157 | } 158 | } 159 | 160 | private function createBrowserLogger(callable $onBrowserLog): LoggerInterface 161 | { 162 | $logger = $this->createMock(LoggerInterface::class); 163 | $logger->expects(self::atLeastOnce()) 164 | ->method('log') 165 | ->willReturn(self::returnCallback(function (string $level, string $message) use ($onBrowserLog) { 166 | if (\strpos($message, "Received a Browser log:") === 0) { 167 | $onBrowserLog(); 168 | } 169 | 170 | return null; 171 | })); 172 | 173 | return $logger; 174 | } 175 | 176 | /** 177 | * @test 178 | * @dontPopulateProperties browser 179 | */ 180 | public function browser_console_calls_are_logged_if_enabled() 181 | { 182 | $browserLogOccured = false; 183 | $logger = $this->createBrowserLogger(function () use (&$browserLogOccured) { 184 | $browserLogOccured = true; 185 | }); 186 | 187 | $puppeteer = new Puppeteer([ 188 | 'log_browser_console' => true, 189 | 'logger' => $logger, 190 | ]); 191 | 192 | $this->browser = $puppeteer->launch($this->browserOptions); 193 | $this->browser->pages()[0]->goto($this->url); 194 | 195 | static::assertTrue($browserLogOccured); 196 | } 197 | 198 | /** 199 | * @test 200 | * @dontPopulateProperties browser 201 | */ 202 | public function browser_console_calls_are_not_logged_if_disabled() 203 | { 204 | $browserLogOccured = false; 205 | $logger = $this->createBrowserLogger(function () use (&$browserLogOccured) { 206 | $browserLogOccured = true; 207 | }); 208 | 209 | $puppeteer = new Puppeteer([ 210 | 'log_browser_console' => false, 211 | 'logger' => $logger, 212 | ]); 213 | 214 | $this->browser = $puppeteer->launch($this->browserOptions); 215 | $this->browser->pages()[0]->goto($this->url); 216 | 217 | static::assertFalse($browserLogOccured); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/doc-generator.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | const yargs = require('yargs/yargs'); 3 | const { hideBin } = require('yargs/helpers'); 4 | 5 | type ObjectMemberAsJson = { [key: string]: string; } 6 | 7 | type ObjectMembersAsJson = { 8 | properties: ObjectMemberAsJson, 9 | getters: ObjectMemberAsJson, 10 | methods: ObjectMemberAsJson, 11 | } 12 | 13 | type ClassAsJson = { name: string } & ObjectMembersAsJson 14 | type MemberContext = 'class'|'literal' 15 | type TypeContext = 'methodReturn' 16 | 17 | class TypeNotSupportedError extends Error { 18 | constructor(message?: string) { 19 | super(message || 'This type is currently not supported.'); 20 | } 21 | } 22 | 23 | interface SupportChecker { 24 | supportsMethodName(methodName: string): boolean; 25 | } 26 | 27 | class JsSupportChecker { 28 | supportsMethodName(methodName: string): boolean { 29 | return true; 30 | } 31 | } 32 | 33 | class PhpSupportChecker { 34 | supportsMethodName(methodName: string): boolean { 35 | return !methodName.includes('$'); 36 | } 37 | } 38 | 39 | interface DocumentationFormatter { 40 | formatProperty(name: string, type: string, context: MemberContext): string 41 | formatGetter(name: string, type: string): string 42 | formatAnonymousFunction(parameters: string, returnType: string): string 43 | formatFunction(name: string, parameters: string, returnType: string): string 44 | formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string 45 | formatTypeAny(): string 46 | formatTypeUnknown(): string 47 | formatTypeVoid(): string 48 | formatTypeUndefined(): string 49 | formatTypeNull(): string 50 | formatTypeBoolean(): string 51 | formatTypeNumber(): string 52 | formatTypeString(): string 53 | formatTypeReference(type: string): string 54 | formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string 55 | formatQualifiedName(left: string, right: string): string 56 | formatIndexedAccessType(object: string, index: string): string 57 | formatLiteralType(value: string): string 58 | formatUnion(types: string[]): string 59 | formatIntersection(types: string[]): string 60 | formatObject(members: string[]): string 61 | formatArray(type: string): string 62 | } 63 | 64 | class JsDocumentationFormatter implements DocumentationFormatter { 65 | formatProperty(name: string, type: string, context: MemberContext): string { 66 | return `${name}: ${type}`; 67 | } 68 | 69 | formatGetter(name: string, type: string): string { 70 | return `${name}: ${type}`; 71 | } 72 | 73 | formatAnonymousFunction(parameters: string, returnType: string): string { 74 | return `(${parameters}) => ${returnType}`; 75 | } 76 | 77 | formatFunction(name: string, parameters: string, returnType: string): string { 78 | return `${name}(${parameters}): ${returnType}`; 79 | } 80 | 81 | formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string { 82 | return `${isVariadic ? '...' : ''}${name}${isOptional ? '?' : ''}: ${type}`; 83 | } 84 | 85 | formatTypeAny(): string { 86 | return 'any'; 87 | } 88 | 89 | formatTypeUnknown(): string { 90 | return 'unknown'; 91 | } 92 | 93 | formatTypeVoid(): string { 94 | return 'void'; 95 | } 96 | 97 | formatTypeUndefined(): string { 98 | return 'undefined'; 99 | } 100 | 101 | formatTypeNull(): string { 102 | return 'null'; 103 | } 104 | 105 | formatTypeBoolean(): string { 106 | return 'boolean'; 107 | } 108 | 109 | formatTypeNumber(): string { 110 | return 'number'; 111 | } 112 | 113 | formatTypeString(): string { 114 | return 'string'; 115 | } 116 | 117 | formatTypeReference(type: string): string { 118 | return type; 119 | } 120 | 121 | formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string { 122 | return `${parentType}<${argumentTypes.join(', ')}>`; 123 | } 124 | 125 | formatQualifiedName(left: string, right: string): string { 126 | return `${left}.${right}`; 127 | } 128 | 129 | formatIndexedAccessType(object: string, index: string): string { 130 | return `${object}[${index}]`; 131 | } 132 | 133 | formatLiteralType(value: string): string { 134 | return `'${value}'`; 135 | } 136 | 137 | formatUnion(types: string[]): string { 138 | return types.join(' | '); 139 | } 140 | 141 | formatIntersection(types: string[]): string { 142 | return types.join(' & '); 143 | } 144 | 145 | formatObject(members: string[]): string { 146 | return `{ ${members.join(', ')} }`; 147 | } 148 | 149 | formatArray(type: string): string { 150 | return `${type}[]`; 151 | } 152 | } 153 | 154 | class PhpDocumentationFormatter implements DocumentationFormatter { 155 | static readonly allowedJsClasses = ['Promise', 'Record', 'Map']; 156 | 157 | constructor( 158 | private readonly resourcesNamespace: string, 159 | private readonly resources: string[], 160 | ) {} 161 | 162 | formatProperty(name: string, type: string, context: MemberContext): string { 163 | return context === 'class' 164 | ? `${type} ${name}` 165 | : `${name}: ${type}`; 166 | } 167 | 168 | formatGetter(name: string, type: string): string { 169 | return `${type} ${name}`; 170 | } 171 | 172 | formatAnonymousFunction(parameters: string, returnType: string): string { 173 | return `callable(${parameters}): ${returnType}`; 174 | } 175 | 176 | formatFunction(name: string, parameters: string, returnType: string): string { 177 | return `${returnType} ${name}(${parameters})`; 178 | } 179 | 180 | formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string { 181 | if (isVariadic && type.endsWith('[]')) { 182 | type = type.slice(0, -2); 183 | } 184 | 185 | const defaultValue = isOptional ? ' = null' : ''; 186 | return `${type} ${isVariadic ? '...' : ''}\$${name}${defaultValue}`; 187 | } 188 | 189 | formatTypeAny(): string { 190 | return 'mixed'; 191 | } 192 | 193 | formatTypeUnknown(): string { 194 | return 'mixed'; 195 | } 196 | 197 | formatTypeVoid(): string { 198 | return 'void'; 199 | } 200 | 201 | formatTypeUndefined(): string { 202 | return 'null'; 203 | } 204 | 205 | formatTypeNull(): string { 206 | return 'null'; 207 | } 208 | 209 | formatTypeBoolean(): string { 210 | return 'bool'; 211 | } 212 | 213 | formatTypeNumber(): string { 214 | return 'float'; 215 | } 216 | 217 | formatTypeString(): string { 218 | return 'string'; 219 | } 220 | 221 | formatTypeReference(type: string): string { 222 | // Allow some specific JS classes to be used in phpDoc 223 | if (PhpDocumentationFormatter.allowedJsClasses.includes(type)) { 224 | return type; 225 | } 226 | 227 | // Prefix PHP resources with their namespace 228 | if (this.resources.includes(type)) { 229 | return `\\${this.resourcesNamespace}\\${type}`; 230 | } 231 | 232 | // If the type ends with "options" then convert it to an associative array 233 | if (/options$/i.test(type)) { 234 | return 'array'; 235 | } 236 | 237 | // Types ending with "Fn" are always callables or strings 238 | if (type.endsWith('Fn')) { 239 | return this.formatUnion(['callable', 'string']); 240 | } 241 | 242 | if (type === 'Function') { 243 | return 'callable'; 244 | } 245 | 246 | if (type === 'PuppeteerLifeCycleEvent') { 247 | return 'string'; 248 | } 249 | 250 | if (type === 'Serializable') { 251 | return this.formatUnion(['int', 'float', 'string', 'bool', 'null', 'array']); 252 | } 253 | 254 | if (type === 'SerializableOrJSHandle') { 255 | return this.formatUnion([this.formatTypeReference('Serializable'), this.formatTypeReference('JSHandle')]); 256 | } 257 | 258 | if (type === 'HandleType') { 259 | return this.formatUnion([this.formatTypeReference('JSHandle'), this.formatTypeReference('ElementHandle')]); 260 | } 261 | 262 | return 'mixed'; 263 | } 264 | 265 | formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string { 266 | // Avoid generics with "mixed" as parent type 267 | if (parentType === 'mixed') { 268 | return 'mixed'; 269 | } 270 | 271 | // Unwrap promises for method return types 272 | if (context === 'methodReturn' && parentType === 'Promise' && argumentTypes.length === 1) { 273 | return argumentTypes[0]; 274 | } 275 | 276 | // Transform Record and Map types to associative arrays 277 | if (['Record', 'Map'].includes(parentType) && argumentTypes.length === 2) { 278 | parentType = 'array'; 279 | } 280 | 281 | return `${parentType}<${argumentTypes.join(', ')}>`; 282 | } 283 | 284 | formatQualifiedName(left: string, right: string): string { 285 | return `mixed`; 286 | } 287 | 288 | formatIndexedAccessType(object: string, index: string): string { 289 | return `mixed`; 290 | } 291 | 292 | formatLiteralType(value: string): string { 293 | return `'${value}'`; 294 | } 295 | 296 | private prepareUnionOrIntersectionTypes(types: string[]): string[] { 297 | // Replace "void" type by "null" 298 | types = types.map(type => type === 'void' ? 'null' : type) 299 | 300 | // Remove duplicates 301 | const uniqueTypes = new Set(types); 302 | return Array.from(uniqueTypes.values()); 303 | } 304 | 305 | formatUnion(types: string[]): string { 306 | const result = this.prepareUnionOrIntersectionTypes(types).join('|'); 307 | 308 | // Convert enums to string type 309 | if (/^('\w+'\|)*'\w+'$/.test(result)) { 310 | return 'string'; 311 | } 312 | 313 | return result; 314 | } 315 | 316 | formatIntersection(types: string[]): string { 317 | return this.prepareUnionOrIntersectionTypes(types).join('&'); 318 | } 319 | 320 | formatObject(members: string[]): string { 321 | return `array{ ${members.join(', ')} }`; 322 | } 323 | 324 | formatArray(type: string): string { 325 | return `${type}[]`; 326 | } 327 | } 328 | 329 | class DocumentationGenerator { 330 | constructor( 331 | private readonly supportChecker: SupportChecker, 332 | private readonly formatter: DocumentationFormatter, 333 | ) {} 334 | 335 | private hasModifierForNode( 336 | node: ts.Node, 337 | modifier: ts.KeywordSyntaxKind 338 | ): boolean { 339 | if (!node.modifiers) { 340 | return false; 341 | } 342 | 343 | return node.modifiers.some((node) => node.kind === modifier); 344 | } 345 | 346 | private isNodeAccessible(node: ts.Node): boolean { 347 | // @ts-ignore 348 | if (node.name && this.getNamedDeclarationAsString(node).startsWith('_')) { 349 | return false; 350 | } 351 | 352 | return ( 353 | this.hasModifierForNode(node, ts.SyntaxKind.PublicKeyword) || 354 | (!this.hasModifierForNode(node, ts.SyntaxKind.ProtectedKeyword) && 355 | !this.hasModifierForNode(node, ts.SyntaxKind.PrivateKeyword)) 356 | ); 357 | } 358 | 359 | private isNodeStatic(node: ts.Node): boolean { 360 | return this.hasModifierForNode(node, ts.SyntaxKind.StaticKeyword); 361 | } 362 | 363 | public getClassDeclarationAsJson(node: ts.ClassDeclaration): ClassAsJson { 364 | return Object.assign( 365 | { name: this.getNamedDeclarationAsString(node) }, 366 | this.getMembersAsJson(node.members, 'class'), 367 | ); 368 | } 369 | 370 | private getMembersAsJson(members: ts.NodeArray, context: MemberContext): ObjectMembersAsJson { 371 | const json: ObjectMembersAsJson = { 372 | properties: {}, 373 | getters: {}, 374 | methods: {}, 375 | }; 376 | 377 | for (const member of members) { 378 | if (!this.isNodeAccessible(member) || this.isNodeStatic(member)) { 379 | continue; 380 | } 381 | 382 | const name = member.name ? this.getNamedDeclarationAsString(member) : null; 383 | 384 | if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) { 385 | json.properties[name] = this.getPropertySignatureOrDeclarationAsString(member, context); 386 | } else if (ts.isGetAccessorDeclaration(member)) { 387 | json.getters[name] = this.getGetAccessorDeclarationAsString(member); 388 | } else if (ts.isMethodDeclaration(member)) { 389 | if (!this.supportChecker.supportsMethodName(name)) { 390 | continue; 391 | } 392 | json.methods[name] = this.getSignatureDeclarationBaseAsString(member); 393 | } 394 | } 395 | 396 | return json; 397 | } 398 | 399 | private getPropertySignatureOrDeclarationAsString( 400 | node: ts.PropertySignature | ts.PropertyDeclaration, 401 | context: MemberContext 402 | ): string { 403 | const type = this.getTypeNodeAsString(node.type); 404 | const name = this.getNamedDeclarationAsString(node); 405 | return this.formatter.formatProperty(name, type, context); 406 | } 407 | 408 | private getGetAccessorDeclarationAsString( 409 | node: ts.GetAccessorDeclaration 410 | ): string { 411 | const type = this.getTypeNodeAsString(node.type); 412 | const name = this.getNamedDeclarationAsString(node); 413 | return this.formatter.formatGetter(name, type); 414 | } 415 | 416 | private getSignatureDeclarationBaseAsString( 417 | node: ts.SignatureDeclarationBase 418 | ): string { 419 | const name = node.name && this.getNamedDeclarationAsString(node); 420 | const parameters = node.parameters 421 | .map(parameter => this.getParameterDeclarationAsString(parameter)) 422 | .join(', '); 423 | 424 | const returnType = this.getTypeNodeAsString(node.type, name ? 'methodReturn' : undefined); 425 | 426 | return name 427 | ? this.formatter.formatFunction(name, parameters, returnType) 428 | : this.formatter.formatAnonymousFunction(parameters, returnType); 429 | } 430 | 431 | private getParameterDeclarationAsString(node: ts.ParameterDeclaration): string { 432 | const name = this.getNamedDeclarationAsString(node); 433 | const type = this.getTypeNodeAsString(node.type); 434 | const isVariadic = node.dotDotDotToken !== undefined; 435 | const isOptional = node.questionToken !== undefined; 436 | return this.formatter.formatParameter(name, type, isVariadic, isOptional); 437 | } 438 | 439 | private getTypeNodeAsString(node: ts.TypeNode, context?: TypeContext): string { 440 | if (node.kind === ts.SyntaxKind.AnyKeyword) { 441 | return this.formatter.formatTypeAny(); 442 | } else if (node.kind === ts.SyntaxKind.UnknownKeyword) { 443 | return this.formatter.formatTypeUnknown(); 444 | } else if (node.kind === ts.SyntaxKind.VoidKeyword) { 445 | return this.formatter.formatTypeVoid(); 446 | } else if (node.kind === ts.SyntaxKind.UndefinedKeyword) { 447 | return this.formatter.formatTypeUndefined(); 448 | } else if (node.kind === ts.SyntaxKind.NullKeyword) { 449 | return this.formatter.formatTypeNull(); 450 | } else if (node.kind === ts.SyntaxKind.BooleanKeyword) { 451 | return this.formatter.formatTypeBoolean(); 452 | } else if (node.kind === ts.SyntaxKind.NumberKeyword) { 453 | return this.formatter.formatTypeNumber(); 454 | } else if (node.kind === ts.SyntaxKind.StringKeyword) { 455 | return this.formatter.formatTypeString(); 456 | } else if (ts.isTypeReferenceNode(node)) { 457 | return this.getTypeReferenceNodeAsString(node, context); 458 | } else if (ts.isIndexedAccessTypeNode(node)) { 459 | return this.getIndexedAccessTypeNodeAsString(node); 460 | } else if (ts.isLiteralTypeNode(node)) { 461 | return this.getLiteralTypeNodeAsString(node); 462 | } else if (ts.isUnionTypeNode(node)) { 463 | return this.getUnionTypeNodeAsString(node, context); 464 | } else if (ts.isIntersectionTypeNode(node)) { 465 | return this.getIntersectionTypeNodeAsString(node, context); 466 | } else if (ts.isTypeLiteralNode(node)) { 467 | return this.getTypeLiteralNodeAsString(node); 468 | } else if (ts.isArrayTypeNode(node)) { 469 | return this.getArrayTypeNodeAsString(node, context); 470 | } else if (ts.isFunctionTypeNode(node)) { 471 | return this.getSignatureDeclarationBaseAsString(node); 472 | } else { 473 | throw new TypeNotSupportedError(); 474 | } 475 | } 476 | 477 | private getTypeReferenceNodeAsString(node: ts.TypeReferenceNode, context?: TypeContext): string { 478 | return this.getGenericTypeReferenceNodeAsString(node, context) || this.getSimpleTypeReferenceNodeAsString(node); 479 | } 480 | 481 | private getGenericTypeReferenceNodeAsString(node: ts.TypeReferenceNode, context?: TypeContext): string | null { 482 | if (!node.typeArguments || node.typeArguments.length === 0) { 483 | return null; 484 | } 485 | 486 | const parentType = this.getSimpleTypeReferenceNodeAsString(node); 487 | const argumentTypes = node.typeArguments.map((node) => this.getTypeNodeAsString(node)); 488 | return this.formatter.formatGeneric(parentType, argumentTypes, context); 489 | } 490 | 491 | private getSimpleTypeReferenceNodeAsString(node: ts.TypeReferenceNode): string { 492 | return ts.isIdentifier(node.typeName) 493 | ? this.formatter.formatTypeReference(this.getIdentifierAsString(node.typeName)) 494 | : this.getQualifiedNameAsString(node.typeName); 495 | } 496 | 497 | private getQualifiedNameAsString(node: ts.QualifiedName): string { 498 | const right = this.getIdentifierAsString(node.right); 499 | const left = ts.isIdentifier(node.left) 500 | ? this.getIdentifierAsString(node.left) 501 | : this.getQualifiedNameAsString(node.left); 502 | 503 | return this.formatter.formatQualifiedName(left, right); 504 | } 505 | 506 | private getIndexedAccessTypeNodeAsString( 507 | node: ts.IndexedAccessTypeNode 508 | ): string { 509 | const object = this.getTypeNodeAsString(node.objectType); 510 | const index = this.getTypeNodeAsString(node.indexType); 511 | return this.formatter.formatIndexedAccessType(object, index); 512 | } 513 | 514 | private getLiteralTypeNodeAsString(node: ts.LiteralTypeNode): string { 515 | if (node.literal.kind === ts.SyntaxKind.NullKeyword) { 516 | return this.formatter.formatTypeNull(); 517 | } else if (node.literal.kind === ts.SyntaxKind.BooleanKeyword) { 518 | return this.formatter.formatTypeBoolean(); 519 | } else if (ts.isLiteralExpression(node.literal)) { 520 | return this.formatter.formatLiteralType(node.literal.text); 521 | } 522 | throw new TypeNotSupportedError(); 523 | } 524 | 525 | private getUnionTypeNodeAsString(node: ts.UnionTypeNode, context?: TypeContext): string { 526 | const types = node.types.map(typeNode => this.getTypeNodeAsString(typeNode, context)); 527 | return this.formatter.formatUnion(types); 528 | } 529 | 530 | private getIntersectionTypeNodeAsString(node: ts.IntersectionTypeNode, context?: TypeContext): string { 531 | const types = node.types.map(typeNode => this.getTypeNodeAsString(typeNode, context)); 532 | return this.formatter.formatIntersection(types); 533 | } 534 | 535 | private getTypeLiteralNodeAsString(node: ts.TypeLiteralNode): string { 536 | const members = this.getMembersAsJson(node.members, 'literal'); 537 | const stringMembers = Object.values(members).map(Object.values); 538 | const flattenMembers = stringMembers.reduce((acc, val) => acc.concat(val), []); 539 | return this.formatter.formatObject(flattenMembers); 540 | } 541 | 542 | private getArrayTypeNodeAsString(node: ts.ArrayTypeNode, context?: TypeContext): string { 543 | const type = this.getTypeNodeAsString(node.elementType, context); 544 | return this.formatter.formatArray(type); 545 | } 546 | 547 | private getNamedDeclarationAsString(node: ts.NamedDeclaration): string { 548 | if (!ts.isIdentifier(node.name)) { 549 | throw new TypeNotSupportedError(); 550 | } 551 | return this.getIdentifierAsString(node.name); 552 | } 553 | 554 | private getIdentifierAsString(node: ts.Identifier): string { 555 | return String(node.escapedText); 556 | } 557 | } 558 | 559 | const { argv } = yargs(hideBin(process.argv)) 560 | .command('$0 ') 561 | .option('resources-namespace', { type: 'string', default: '' }) 562 | .option('resources', { type: 'array', default: [] }) 563 | .option('pretty', { type: 'boolean', default: false }) 564 | 565 | let supportChecker, formatter; 566 | switch (argv.language.toUpperCase()) { 567 | case 'JS': 568 | supportChecker = new JsSupportChecker(); 569 | formatter = new JsDocumentationFormatter(); 570 | break; 571 | case 'PHP': 572 | supportChecker = new PhpSupportChecker(); 573 | formatter = new PhpDocumentationFormatter(argv.resourcesNamespace, argv.resources); 574 | break; 575 | default: 576 | console.error(`Unsupported "${argv.language}" language.`); 577 | process.exit(1); 578 | } 579 | 580 | const docGenerator = new DocumentationGenerator(supportChecker, formatter); 581 | const program = ts.createProgram(argv.definitionFiles, {}); 582 | const classes = {}; 583 | 584 | for (const fileName of argv.definitionFiles) { 585 | const sourceFile = program.getSourceFile(fileName); 586 | 587 | ts.forEachChild(sourceFile, node => { 588 | if (ts.isClassDeclaration(node)) { 589 | const classAsJson = docGenerator.getClassDeclarationAsJson(node); 590 | classes[classAsJson.name] = classAsJson; 591 | } 592 | }); 593 | } 594 | 595 | process.stdout.write(JSON.stringify(classes, null, argv.pretty ? 2 : null)); 596 | --------------------------------------------------------------------------------