├── 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 ](https://github.com/GoogleChrome/puppeteer), developed by Google to interact with Chromium, from [PuPHPeteer ](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 **: 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 **: 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 **: report the bug [in Puppeteer's issues](https://github.com/GoogleChrome/puppeteer/issues).
15 | 2. it is reproducible only with **PuPHPeteer **: 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 | [](http://php.net/)
6 | [](https://packagist.org/packages/nesk/puphpeteer)
7 | [](https://nodejs.org/)
8 | [](https://www.npmjs.com/package/@nesk/puphpeteer)
9 | [](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 |
--------------------------------------------------------------------------------