├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── contributing.md └── workflows │ └── tests.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── console ├── composer.json ├── package.json ├── phpunit.xml ├── src ├── Command │ └── GenerateDocumentationCommand.php ├── Puppeteer.php ├── PuppeteerConnectionDelegate.js ├── PuppeteerProcessDelegate.php ├── Resources │ ├── Accessibility.php │ ├── Browser.php │ ├── BrowserContext.php │ ├── BrowserFetcher.php │ ├── CDPSession.php │ ├── ConsoleMessage.php │ ├── Coverage.php │ ├── Dialog.php │ ├── ElementHandle.php │ ├── EventEmitter.php │ ├── ExecutionContext.php │ ├── FileChooser.php │ ├── Frame.php │ ├── HTTPRequest.php │ ├── HTTPResponse.php │ ├── JSHandle.php │ ├── Keyboard.php │ ├── Mouse.php │ ├── Page.php │ ├── SecurityDetails.php │ ├── Target.php │ ├── TimeoutError.php │ ├── Touchscreen.php │ ├── Tracing.php │ └── WebWorker.php ├── Traits │ ├── AliasesEvaluationMethods.php │ └── AliasesSelectionMethods.php ├── doc-generator.ts └── get-puppeteer-version.js └── tests ├── DownloadTest.php ├── PuphpeteerTest.php ├── ResourceInstantiator.php ├── RiskyResource.php ├── TestCase.php ├── UntestableResource.php └── resources ├── denys-barabanov-jKcFmXCfaQ8-unsplash.jpg ├── index.html ├── puphpeteer-logo.png ├── stylesheet.css └── worker.js /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | phpunit: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | php-version: [7.3, 7.4, 8.0] 9 | composer-flags: [null, --prefer-lowest] 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | - uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: ${{ matrix.php-version }} 16 | coverage: none 17 | - run: composer update ${{ matrix.composer-flags }} --no-interaction --no-progress --prefer-dist --ansi 18 | - run: npm install 19 | - run: ./vendor/bin/phpunit --color=always 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build/ 2 | /node_modules/ 3 | /vendor/ 4 | .phpunit.result.cache 5 | composer.lock 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚨 This project is not maintained anymore 2 | 3 | As I write these lines, it's been nearly two years since the latest release of PuPHPeteer. Despite the enthusiasm around this project, I no longer have the motivation to support its development, mainly because it never really had any use to me. So its time to be honest with you, PuPHPeteer is no longer maintained. 4 | 5 | However, here's a list of forks maintained by the community: 6 | 7 | - [zoonru/puphpeteer](https://github.com/zoonru/puphpeteer) 8 | - [NigelCunningham/puphpeteer](https://github.com/NigelCunningham/puphpeteer) 9 | 10 | If you create a fork and plan to maintain it, let me know and I will link it here. 11 | 12 | # PuPHPeteer 13 | 14 | 15 | 16 | [![PHP Version](https://img.shields.io/packagist/php-v/nesk/puphpeteer.svg?style=flat-square)](http://php.net/) 17 | [![Composer Version](https://img.shields.io/packagist/v/nesk/puphpeteer.svg?style=flat-square&label=Composer)](https://packagist.org/packages/nesk/puphpeteer) 18 | [![Node Version](https://img.shields.io/node/v/@nesk/puphpeteer.svg?style=flat-square&label=Node)](https://nodejs.org/) 19 | [![NPM Version](https://img.shields.io/npm/v/@nesk/puphpeteer.svg?style=flat-square&label=NPM)](https://www.npmjs.com/package/@nesk/puphpeteer) 20 | [![Build Status](https://img.shields.io/travis/nesk/puphpeteer.svg?style=flat-square&label=Build%20Status)](https://travis-ci.org/nesk/puphpeteer) 21 | 22 | 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. 23 | 24 | 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: 25 | 26 | **Example** - navigating to https://example.com and saving a screenshot as *example.png*: 27 | 28 | ```php 29 | use Nesk\Puphpeteer\Puppeteer; 30 | 31 | $puppeteer = new Puppeteer; 32 | $browser = $puppeteer->launch(); 33 | 34 | $page = $browser->newPage(); 35 | $page->goto('https://example.com'); 36 | $page->screenshot(['path' => 'example.png']); 37 | 38 | $browser->close(); 39 | ``` 40 | 41 | **Example** - evaluate a script in the context of the page: 42 | 43 | ```php 44 | use Nesk\Puphpeteer\Puppeteer; 45 | use Nesk\Rialto\Data\JsFunction; 46 | 47 | $puppeteer = new Puppeteer; 48 | 49 | $browser = $puppeteer->launch(); 50 | $page = $browser->newPage(); 51 | $page->goto('https://example.com'); 52 | 53 | // Get the "viewport" of the page, as reported by the page. 54 | $dimensions = $page->evaluate(JsFunction::createWithBody(" 55 | return { 56 | width: document.documentElement.clientWidth, 57 | height: document.documentElement.clientHeight, 58 | deviceScaleFactor: window.devicePixelRatio 59 | }; 60 | ")); 61 | 62 | printf('Dimensions: %s', print_r($dimensions, true)); 63 | 64 | $browser->close(); 65 | ``` 66 | 67 | ## Requirements and installation 68 | 69 | This package requires PHP >= 7.3 and Node >= 8. 70 | 71 | Install it with these two command lines: 72 | 73 | ```shell 74 | composer require nesk/puphpeteer 75 | npm install @nesk/puphpeteer 76 | ``` 77 | 78 | ## Notable differences between PuPHPeteer and Puppeteer 79 | 80 | ### Puppeteer's class must be instantiated 81 | 82 | Instead of requiring Puppeteer: 83 | 84 | ```js 85 | const puppeteer = require('puppeteer'); 86 | ``` 87 | 88 | You have to instantiate the `Puppeteer` class: 89 | 90 | ```php 91 | $puppeteer = new Puppeteer; 92 | ``` 93 | 94 | This will create a new Node process controlled by PHP. 95 | 96 | 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: 97 | 98 | ```php 99 | [ 100 | // Logs the output of Browser's console methods (console.log, console.debug, etc...) to the PHP logger 101 | 'log_browser_console' => false, 102 | ] 103 | ``` 104 | 105 |
106 | ⏱ Want to use some timeouts higher than 30 seconds in Puppeteer's API?
107 | 108 | If you use some timeouts higher than 30 seconds, you will have to set a higher value for the `read_timeout` option (default: `35`): 109 | 110 | ```php 111 | $puppeteer = new Puppeteer([ 112 | 'read_timeout' => 65, // In seconds 113 | ]); 114 | 115 | $puppeteer->launch()->newPage()->goto($url, [ 116 | 'timeout' => 60000, // In milliseconds 117 | ]); 118 | ``` 119 |
120 | 121 | ### No need to use the `await` keyword 122 | 123 | With PuPHPeteer, every method call or property getting/setting is synchronous. 124 | 125 | ### Some methods have been aliased 126 | 127 | The following methods have been aliased because PHP doesn't support the `$` character in method names: 128 | 129 | - `$` => `querySelector` 130 | - `$$` => `querySelectorAll` 131 | - `$x` => `querySelectorXPath` 132 | - `$eval` => `querySelectorEval` 133 | - `$$eval` => `querySelectorAllEval` 134 | 135 | Use these aliases just like you would have used the original methods: 136 | 137 | ```php 138 | $divs = $page->querySelectorAll('div'); 139 | ``` 140 | 141 | ### Evaluated functions must be created with `JsFunction` 142 | 143 | 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. 144 | 145 | ```php 146 | use Nesk\Rialto\Data\JsFunction; 147 | 148 | $pageFunction = JsFunction::createWithParameters(['element']) 149 | ->body("return element.textContent"); 150 | ``` 151 | 152 | ### Exceptions must be caught with `->tryCatch` 153 | 154 | 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`. 155 | 156 | To avoid that, you can ask Node to catch these errors by prepending your instruction with `->tryCatch`: 157 | 158 | ```php 159 | use Nesk\Rialto\Exceptions\Node; 160 | 161 | try { 162 | $page->tryCatch->goto('invalid_url'); 163 | } catch (Node\Exception $exception) { 164 | // Handle the exception... 165 | } 166 | ``` 167 | 168 | Instead, a `Node\Exception` will be thrown, the Node process will stay alive and usable. 169 | 170 | ## License 171 | 172 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 173 | 174 | ## Logo attribution 175 | 176 | PuPHPeteer's logo is composed of: 177 | 178 | - [Puppet](https://thenounproject.com/search/?q=puppet&i=52120) by Luis Prado from [the Noun Project](http://thenounproject.com/). 179 | - [Elephant](https://thenounproject.com/search/?q=elephant&i=954119) by Lluisa Iborra from [the Noun Project](http://thenounproject.com/). 180 | 181 | Thanks to [Laravel News](https://laravel-news.com/) for picking the icons and colors of the logo. 182 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new GenerateDocumentationCommand) 11 | ->getApplication() 12 | ->run(); 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nesk/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 | "nesk/rialto": "^1.2.0", 24 | "psr/log": "^1.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", 31 | "symfony/console": "^4.0|^5.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Nesk\\Puphpeteer\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Nesk\\Puphpeteer\\Tests\\": "tests/" 41 | } 42 | }, 43 | "scripts": { 44 | "post-install-cmd": "npm install", 45 | "test": "./vendor/bin/phpunit" 46 | }, 47 | "config": { 48 | "sort-packages": true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | tests 8 | 9 | 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/PuppeteerProcessDelegate.php: -------------------------------------------------------------------------------- 1 | $options = null) 9 | */ 10 | class Accessibility extends BasicResource 11 | { 12 | // 13 | } 14 | -------------------------------------------------------------------------------- /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/BrowserContext.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/Dialog.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 | -------------------------------------------------------------------------------- /src/Resources/EventEmitter.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/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/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/Resources/JSHandle.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/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/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 | -------------------------------------------------------------------------------- /src/Resources/SecurityDetails.php: -------------------------------------------------------------------------------- 1 | $options = null) 9 | * @method mixed stop() 10 | */ 11 | class Tracing extends BasicResource 12 | { 13 | // 14 | } 15 | -------------------------------------------------------------------------------- /src/Resources/WebWorker.php: -------------------------------------------------------------------------------- 1 | __call('$eval', $arguments); 16 | } 17 | 18 | public function querySelectorAllEval(...$arguments) 19 | { 20 | return $this->__call('$$eval', $arguments); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Traits/AliasesSelectionMethods.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/RiskyResource.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/UntestableResource.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Document 5 | 6 | 7 | 8 | 9 | 10 |

Example Page

11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/resources/puphpeteer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rialto-php/puphpeteer/ac3705e876ec4a6fb844c423be89638df35fa78e/tests/resources/puphpeteer-logo.png -------------------------------------------------------------------------------- /tests/resources/stylesheet.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-transform: lowercase; 3 | } 4 | -------------------------------------------------------------------------------- /tests/resources/worker.js: -------------------------------------------------------------------------------- 1 | // There's nothing to do, just wait. 2 | --------------------------------------------------------------------------------