diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 5c1108f2..590c9593 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,53 +1,132 @@ -# Contributor Covenant Code of Conduct - -TLDL; Saying "hi", "please", "thanks" and being polite and showing empathy has never hurt anyone and makes everyone happy. -We are humans not robots, so let's make the open source experience an enjoyable one for humans that take part in it. +# CONTRIBUTOR COVENANT CODE OF CONDUCT ## Our Pledge -In the interest of fostering an open and welcoming environment, -we as contributors and maintainers pledge to making participation in our project and our community -a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, -gender identity and expression, level of experience, nationality, personal appearance, race, religion, -or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at sghzal@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hello@gjcampbell.co.uk. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8388d2ec..294ffb34 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,10 +8,10 @@ Issues Reporting an issue is the foremost way to contribute. -When reporting an issue, try to give as much details as possible. Ability to reproduce the issue is a priority. +When reporting an issue, try to give as many details as possible. Ability to reproduce the issue is a priority. If you give an example that reproduces your issue you will have more chances to get it fixed. -Those details include: +Those details include: - php version - chrome version @@ -33,20 +33,20 @@ You will be provided with an output of what is happening within the library and Tests ----- -Writting test is also a great way to contribute because it ensures that the library will remain consistent after any upgrade. +Writing test is also a great way to contribute because it ensures that the library will remain consistent after any upgrade. Implementing new features or fixing bugs ---------------------------------------- Implementing new features will allow anyone to take profit of your work. Just remember to rise an issue and discuss it before to make sure that the work goes in the right direction and you work will be approved. -In addition all contributions must be tested following as much as possible the current test structure: +In addition, all contributions must be tested following as much as possible the current test structure: - One class = one test file in ``test/suites`` and the class must be annotated with ``@covers``. - One class method = one method in the test class. Look at current tests in ``test/suites`` for more details. -Writting documentation +Writing documentation ---------------------- We encourage anyone to improve the documentation by adding new example or by fixing current one that would be wrong or outdated. diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index bff3e1b2..c7f0e44e 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.3' + php-version: '7.4' tools: composer:v2 coverage: none diff --git a/.styleci.yml b/.styleci.yml index a923f78e..a70b1b34 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,5 +1,7 @@ preset: symfony +version: 7.4 + risky: true enabled: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c00f217..aa9e01d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,41 @@ # CHANGELOG +## 1.4.1 (2022-03-25) + +* Added fallback to css layout metrics +* Added missing destroyed setting +* Prevent `Node::querySelector` from returning nodeId `0` +* Fixed "What's new" page opening on startup +* More fixes to enable eventual PHP 8.1 support + + +## 1.4.0 (2022-01-23) + +* Added support for `--no-proxy-server` and `--proxy-bypass-list` +* Added timeout option to `Page::getHtml` +* Added `Node::sendFiles` method + + +## 1.3.1 (2022-01-23) + +* Fixed issues with `Keyboard::typeText` with multibyte strings +* Fixed issues with retina and scaled displays +* Fixed issues with timeouts if system time changes +* Fixed `Mouse::find()` after cursor has moved + + +## 1.3.0 (2021-12-07) + +* Added support for setting HTTP headers +* Added support for `psr/log` 2 and 3 + + +## 1.2.1 (2021-12-07) + +* Partial PHP 8.1 support + + ## 1.2.0 (2021-11-20) * Dropped `--disable-default-apps` and `--disable-extensions` by default diff --git a/LICENSE b/LICENSE index c0babdd7..034561bd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,8 @@ The MIT License (MIT) Copyright (c) 2017-2020 Soufiane Ghzal -Copyright (c) 2020-2021 Graham Campbell -Copyright (c) 2020-2021 Enrico Dias +Copyright (c) 2020-2022 Graham Campbell +Copyright (c) 2020-2022 Enrico Dias Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 84903eaf..68e70a41 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Can be used synchronously and asynchronously! - Open chrome or chromium browser from php - Create pages and navigate to pages - Take screenshots -- Evaluate javascript in the page +- Evaluate javascript on the page - Make PDF - Emulate mouse - Emulate keyboard @@ -26,7 +26,7 @@ Happy browsing! Requires PHP 7.3-8.0 and a chrome/chromium 65+ executable. -Note that the library is only tested on Linux but is compatible with MacOS and Windows. +Note that the library is only tested on Linux but is compatible with macOS and Windows. ## Installation @@ -41,8 +41,7 @@ $ composer require chrome-php/chrome ## Usage -It uses a simple and understandable API to start chrome, to open pages, to take screenshots, -to crawl websites... and almost everything that you can do with chrome as a human. +It uses a simple and understandable API to start chrome, to open pages, take screenshots, crawl websites... and almost everything that you can do with chrome as a human. ```php use HeadlessChromium\BrowserFactory; @@ -53,7 +52,7 @@ $browserFactory = new BrowserFactory(); $browser = $browserFactory->createBrowser(); try { - // creates a new page and navigate to an url + // creates a new page and navigate to an URL $page = $browser->createPage(); $page->navigate('http://example.com')->waitForNavigation(); @@ -76,7 +75,7 @@ try { When starting, the factory will look for the environment variable ``"CHROME_PATH"`` to use as the chrome executable. If the variable is not found, it will try to guess the correct executable path according to your OS or use ``"chrome"`` as the default. -You also explicitly set any executable of your choice when creating a new object. For instance ``"chromium-browser"``: +You are also able to explicitly set up any executable of your choice when creating a new object. For instance ``"chromium-browser"``: ```php use HeadlessChromium\BrowserFactory; @@ -95,7 +94,7 @@ use HeadlessChromium\BrowserFactory; $browserFactory = new BrowserFactory(); $browser = $browserFactory->createBrowser([ - 'headless' => false, // disable headless mode + 'headless' => false, // disable headless mode ]); ``` @@ -108,7 +107,7 @@ Other debug options: ] ``` -About ``debugLogger``: this can be any of a resource string, a resource or an object implementing +About ``debugLogger``: this can be any of a resource string, a resource, or an object implementing ``LoggerInterface`` from Psr\Log (such as [monolog](https://github.com/Seldaek/monolog) or [apix/log](https://github.com/apix/log)). @@ -117,35 +116,65 @@ or [apix/log](https://github.com/apix/log)). ### Browser Factory +Options set directly in the `createBrowser` method will be used only for a single browser creation. The default options will be ignored. + ```php use HeadlessChromium\BrowserFactory; $browserFactory = new BrowserFactory(); $browser = $browserFactory->createBrowser([ - 'windowSize' => [1920, 1000], - 'enableImages' => false, + 'windowSize' => [1920, 1000], + 'enableImages' => false, +]); + +// this browser will be created without any options +$browser2 = $browserFactory->createBrowser(); +``` + +Options set using the `setOptions` and `addOptions` methods will persist. + +```php +$browserFactory->setOptions([ + 'windowSize' => [1920, 1000], ]); + +// both browser will have the same 'windowSize' option +$browser1 = $browserFactory->createBrowser(); +$browser2 = $browserFactory->createBrowser(); + +$browserFactory->addOptions(['enableImages' => false]); + +// this browser will have both the 'windowSize' and 'enableImages' options +$browser3 = $browserFactory->createBrowser(); + +$browserFactory->addOptions(['enableImages' => true]); + +// this browser will have the previous 'windowSize', but 'enableImages' will be true +$browser4 = $browserFactory->createBrowser(); ``` -#### Options +#### Available options Here are the options available for the browser factory: | Option name | Default | Description | |---------------------------|---------|----------------------------------------------------------------------------------------------| | `connectionDelay` | `0` | Delay to apply between each operation for debugging purposes | -| `customFlags` | none | Array of flags to pass to the command line. Eg: `['--option1', '--option2=someValue']` | +| `customFlags` | none | An array of flags to pass to the command line. Eg: `['--option1', '--option2=someValue']` | | `debugLogger` | `null` | A string (e.g "php://stdout"), or resource, or PSR-3 logger instance to print debug messages | | `enableImages` | `true` | Toggles loading of images | -| `envVariables` | none | Array of environment variables to pass to the process (example DISPLAY variable) | +| `envVariables` | none | An array of environment variables to pass to the process (example DISPLAY variable) | +| `headers` | none | An array of custom HTTP headers | | `headless` | `true` | Enable or disable headless mode | | `ignoreCertificateErrors` | `false` | Set chrome to ignore ssl errors | | `keepAlive` | `false` | Set to `true` to keep alive the chrome instance when the script terminates | -| `noSandbox` | `false` | Useful to run in a docker container | +| `noSandbox` | `false` | Enable no sandbox mode, useful to run in a docker container | +| `noProxyServer` | `false` | Don't use a proxy server, always make direct connections. Overrides other proxy settings. | +| `proxyBypassList` | none | Specifies a list of hosts for whom we bypass proxy settings and use direct connections | | `proxyServer` | none | Proxy server to use. usage: `127.0.0.1:8080` (authorisation with credentials does not work) | | `sendSyncDefaultTimeout` | `5000` | Default timeout (ms) for sending sync messages | | `startupTimeout` | `30` | Maximum time in seconds to wait for chrome to start | -| `userAgent` | none | User agent to use for the whole browser (see page api for alternative) | +| `userAgent` | none | User agent to use for the whole browser (see page API for alternative) | | `userDataDir` | none | Chrome user data dir (default: a new empty dir is generated temporarily) | | `windowSize` | none | Size of the window. usage: `$width, $height` - see also Page::setViewport | @@ -155,16 +184,12 @@ Here are the options available for the browser factory: ```php $page = $browser->createPage(); - -// destination can be specified -$uri = 'http://example.com'; -$page = $browser->createPage($uri); ``` #### Close the browser ```php - $browser->close(); +$browser->close(); ``` ### Set a script to evaluate before every page created by this browser will navigate @@ -181,7 +206,7 @@ window.navigator.permissions.query = (parameters) => ( ### Page API -#### Navigate to an url +#### Navigate to an URL ```php // navigate @@ -191,7 +216,7 @@ $navigation = $page->navigate('http://example.com'); $navigation->waitForNavigation(); ``` -When Using ``$navigation->waitForNavigation()`` you will wait for 30sec until the page event "loaded" is triggered. +When using ``$navigation->waitForNavigation()`` you will wait for 30sec until the page event "loaded" is triggered. You can change the timeout or the event to listen for: ```php @@ -205,9 +230,9 @@ Available events (in the order they trigger): - ``Page::LOAD``: (default) page and all resources are loaded - ``Page::NETWORK_IDLE``: page has loaded, and no network activity has occurred for at least 500ms -When you want to wait for the page to navigate there are 2 main issues that may occur. -First the page is too long to load and second the page you were waiting to be loaded has been replaced. -The good news is that you can handle those issues using a good old try catch: +When you want to wait for the page to navigate 2 main issues may occur. +First, the page is too long to load and second, the page you were waiting to be loaded has been replaced. +The good news is that you can handle those issues using a good old try-catch: ```php use HeadlessChromium\Exception\OperationTimedOut; @@ -241,7 +266,7 @@ $value = $evaluation->getReturnValue(); ``` -Sometime the script you evaluate will click a link or submit a form, in this case the page will reload and you +Sometimes the script you evaluate will click a link or submit a form, in this case, the page will reload and you will want to wait for the new page to reload. You can achieve this by using ``$page->evaluate('some js that will reload the page')->waitForPageReload()``. @@ -249,7 +274,7 @@ An example is available in [form-submit.php](./examples/form-submit.php) #### Call a function -This is an alternative to ``evaluate`` that allows to call a given function with the given arguments in the page context: +This is an alternative to ``evaluate`` that allows calling a given function with the given arguments in the page context: ```php $evaluation = $page->callFunction( @@ -272,7 +297,7 @@ $page->addScriptTag([ $page->evaluate('$(".my.element").html()'); ``` -You can also use an url to feed the src attribute: +You can also use an URL to feed the src attribute: ```php $page->addScriptTag([ @@ -310,14 +335,14 @@ $page->addPreScript($script, ['onLoad' => true]); #### Set viewport size -This features allows to change the size of the viewport (emulation) for the current page without affecting the size of +This feature allows changing the size of the viewport (emulation) for the current page without affecting the size of all the browser's pages (see also option ``"windowSize"`` of [BrowserFactory::createBrowser](#options)). ```php $width = 600; $height = 300; $page->setViewport($width, $height) - ->await(); // wait for operation to complete + ->await(); // wait for the operation to complete ``` #### Make a screenshot @@ -339,15 +364,9 @@ $screenshot = $page->screenshot([ $screenshot->saveToFile('/some/place/file.jpg'); ``` -**choose an area** - -You can use the option "clip" in order to choose an area for the screenshot (TODO exemple) - -**take a full page screenshot** +**Screenshot an area on a page** -You can also take a screenshot for the full layout (not only the layout) using ``$page->getFullPageClip`` (TODO exemple) - -TODO ``Page.getFullPageClip();`` +You can use the option "clip" to choose an area on a page for the screenshot ```php use HeadlessChromium\Clip; @@ -374,6 +393,27 @@ $screenshot = $page->screenshot([ $screenshot->saveToFile('/some/place/file.jpg'); ``` +**Full-page screenshot** + +You can also take a screenshot for the full-page layout (not only the viewport) using ``$page->getFullPageClip`` with attribute ``captureBeyondViewport = true`` + +```php +// navigate +$navigation = $page->navigate('https://example.com'); + +// wait for the page to be loaded +$navigation->waitForNavigation(); + +$screenshot = $page->screenshot([ + 'captureBeyondViewport' => true, + 'clip' => $page->getFullPageClip(), + 'format' => 'jpeg', // default to 'png' - possible values: 'png', 'jpeg', +]); + +// save the screenshot +$screenshot->saveToFile('/some/place/file.jpg'); +``` + #### Print as PDF ```php @@ -387,16 +427,16 @@ $options = [ 'landscape' => true, // default to false 'printBackground' => true, // default to false 'displayHeaderFooter' => true, // default to false - 'preferCSSPageSize' => true, // default to false ( reads parameters directly from @page ) - 'marginTop' => 0.0, // defaults to ~0.4 (must be float, value in inches) - 'marginBottom' => 1.4, // defaults to ~0.4 (must be float, value in inches) - 'marginLeft' => 5.0, // defaults to ~0.4 (must be float, value in inches) - 'marginRight' => 1.0, // defaults to ~0.4 (must be float, value in inches) - 'paperWidth' => 6.0, // defaults to 8.5 (must be float, value in inches) - 'paperHeight' => 6.0, // defaults to 8.5 (must be float, value in inches) + 'preferCSSPageSize' => true, // default to false (reads parameters directly from @page) + 'marginTop' => 0.0, // defaults to ~0.4 (must be a float, value in inches) + 'marginBottom' => 1.4, // defaults to ~0.4 (must be a float, value in inches) + 'marginLeft' => 5.0, // defaults to ~0.4 (must be a float, value in inches) + 'marginRight' => 1.0, // defaults to ~0.4 (must be a float, value in inches) + 'paperWidth' => 6.0, // defaults to 8.5 (must be a float, value in inches) + 'paperHeight' => 6.0, // defaults to 8.5 (must be a float, value in inches) 'headerTemplate' => '
foo
', // see details above 'footerTemplate' => '
foo
', // see details above - 'scale' => 1.2, // defaults to 1.0 (must be float) + 'scale' => 1.2, // defaults to 1.0 (must be a float) ]; // print as pdf (in memory binaries) @@ -417,9 +457,9 @@ header('Pragma: public'); echo base64_decode($pdf->getBase64()); ``` -Options `headerTempalte` and `footerTempalte`: +Options `headerTemplate` and `footerTemplate`: -Should be valid HTML markup with following classes used to inject printing values into them: +Should be valid HTML markup with the following classes used to inject printing values into them: - date: formatted print date - title: document title - url: document location @@ -441,17 +481,17 @@ The mouse API is dependent on the page instance and allows you to control the mo ```php $page->mouse() - ->move(10, 20) // Moves mouse to position x=10;y=20 - ->click() // left click on position set above - ->move(100, 200, ['steps' => 5]) // move mouse to x=100;y=200 in 5 equal steps - ->click(['button' => Mouse::BUTTON_RIGHT]; // right click on position set above + ->move(10, 20) // Moves mouse to position x=10; y=20 + ->click() // left-click on position set above + ->move(100, 200, ['steps' => 5]) // move mouse to x=100; y=200 in 5 equal steps + ->click(['button' => Mouse::BUTTON_RIGHT]; // right-click on position set above // given the last click was on a link, the next step will wait // for the page to load after the link was clicked $page->waitForReload(); ``` -You can emulate the mouse wheel to scroll up and down in a page, frame or element. +You can emulate the mouse wheel to scroll up and down in a page, frame, or element. ```php $page->mouse() @@ -465,7 +505,7 @@ The `find` method will search for elements using [querySelector](https://develop ```php try { - $page->mouse()->find('#a')->click(); // find and click on element with id "a" + $page->mouse()->find('#a')->click(); // find and click at an element with id "a" $page->mouse()->find('.a', 10); // find the 10th or last element with class "a" } catch (ElementNotFoundException $exception) { @@ -473,7 +513,7 @@ try { } ``` -This method will attempt scroll right and down to bring the element to the visible screen. If the element is inside an internal scrollable section, try moving the mouse to inside that section first. +This method will attempt to scroll right and down to bring the element to the visible screen. If the element is inside an internal scrollable section, try moving the mouse to inside that section first. ### Keyboard API @@ -488,29 +528,29 @@ $page->keyboard() To impersonate a real user you may want to add a delay between each keystroke using the ```setKeyInterval``` method: ```php -$page->keyboard()->setKeyInterval(10); // sets a delay of 10 miliseconds between keystrokes +$page->keyboard()->setKeyInterval(10); // sets a delay of 10 milliseconds between keystrokes ``` #### Key combinations -The methods `press`, `type` and `release` can be used to send key combinations such as `ctrl + v`. +The methods `press`, `type`, and `release` can be used to send key combinations such as `ctrl + v`. ```php // ctrl + a to select all text $page->keyboard() - ->press(' control ') // key names are case insensitive and trimmed - ->type('a') // press and release + ->press('control') // key names are case insensitive and trimmed + ->type('a') // press and release ->release('Control'); // ctrl + c to copy and ctrl + v to paste it twice $page->keyboard() ->press('Ctrl') // alias for Control ->type('c') - ->type('V') // upper and lower case should behave the same way - ->release(); // release all + ->type('V') // upper and lower cases should behave the same way + ->release(); // release all ``` -You can press the same key several times in sequence, this is equivalent of a user pressing and holding the key. The release event, however, will be sent only once per key. +You can press the same key several times in sequence, this is the equivalent to a user pressing and holding the key. The release event, however, will be sent only once per key. #### Key aliases @@ -537,7 +577,7 @@ $page = $browser->createPage(); $page->setCookies([ Cookie::create('name', 'value', [ 'domain' => 'example.com', - 'expires' => time() + 3600 // expires in 1 day + 'expires' => time() + 3600 // expires in 1 hour ]) ])->await(); @@ -579,13 +619,13 @@ if ($cookieBar) { ### Set user agent -You can set an user agent per page: +You can set up a user-agent per page: ```php -$page->setUserAgent('my user agent'); +$page->setUserAgent('my user-agent'); ``` -See also BrowserFactory option ``userAgent`` to set it for the whole browser. +See also BrowserFactory option ``userAgent`` to set up it for the whole browser. Advanced usage @@ -600,7 +640,7 @@ Example: use HeadlessChromium\Communication\Connection; use HeadlessChromium\Communication\Message; -// chrome devtools uri +// chrome devtools URI $webSocketUri = 'ws://127.0.0.1:9222/devtools/browser/xxx'; // create a connection @@ -614,7 +654,7 @@ $responseReader = $connection->sendMessage(new Message('Target.activateTarget', $response = $responseReader->waitForResponse(1000); ``` -### Create a session and send message to the target +### Create a session and send a message to the target ```php // given a target id @@ -632,7 +672,7 @@ $response = $session->sendMessageSync(new Message('Page.reload')); You can ease the debugging by setting a delay before each operation is made: ```php - $connection->setConnectionDelay(500); // wait for 500 ms between each operation to ease debugging + $connection->setConnectionDelay(500); // wait for 500ms between each operation to ease debugging ``` ### Browser (standalone) @@ -641,10 +681,10 @@ You can ease the debugging by setting a delay before each operation is made: use HeadlessChromium\Communication\Connection; use HeadlessChromium\Browser; -// chrome devtools uri +// chrome devtools URI $webSocketUri = 'ws://127.0.0.1:9222/devtools/browser/xxx'; -// create connection given a web socket uri +// create connection given a WebSocket URI $connection = new Connection($webSocketUri); $connection->connect(); @@ -654,7 +694,7 @@ $browser = new Browser($connection); ### Interacting with DOM -Find one element on page by css selector: +Find one element on a page by CSS selector: ```php $page = $browser->createPage(); @@ -663,14 +703,14 @@ $page->navigate('http://example.com')->waitForNavigation(); $elem = $page->dom()->querySelector('#index_email'); ``` -Find all elements in another element by css selector: +Find all elements inside another element by CSS selector: ```php $elem = $page->dom()->querySelector('#index_email'); $elem->querySelectorAll('a.link'); ``` -Find all elements on page by xpath selector: +Find all elements on a page by XPath selector: ```php $page = $browser->createPage(); @@ -679,17 +719,17 @@ $page->navigate('http://example.com')->waitForNavigation(); $elem = $page->dom()->search('//div/*/a'); ``` -You can send text to element or click on it: +You can send out a text to an element or click on it: ```php $elem->click(); $elem->sendKeys('Sample text'); ``` -You can upload file to file from input: +You can upload file to file from the input: ```php -$elem->uploadFile('/path/to/file'); +$elem->sendFile('/path/to/file'); ``` You can get element text or attribute: diff --git a/composer.json b/composer.json index 3044cf40..707551b0 100644 --- a/composer.json +++ b/composer.json @@ -6,25 +6,28 @@ "authors": [ { "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk" + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" }, { "name": "Enrico Dias", - "email": "enricodias@gmail.com" + "email": "enricodias@gmail.com", + "homepage": "https://github.com/enricodias" } ], "require": { "php": "^7.3 || ^8.0", - "apix/log": "^1.2", - "chrome-php/wrench": "^1.0", + "chrome-php/wrench": "^1.1", "evenement/evenement": "^3.0.1", - "psr/log": "^1.1", + "monolog/monolog": "^1.26 || ^2.2", + "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/filesystem": "^4.4 || ^5.0 || ^6.0", + "symfony/polyfill-mbstring": "^1.23", "symfony/process": "^4.4 || ^5.0 || ^6.0" }, "require-dev":{ "bamarni/composer-bin-plugin": "^1.4.1", - "phpunit/phpunit": "^9.5.9", + "phpunit/phpunit": "^9.5.10", "symfony/var-dumper": "^4.4 || ^5.0 || ^6.0" }, "autoload":{ @@ -38,6 +41,9 @@ } }, "config": { + "allow-plugins": { + "bamarni/composer-bin-plugin": true + }, "preferred-install": "dist" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1747e869..59691007 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -4,6 +4,7 @@ bootstrap="vendor/autoload.php" forceCoversAnnotation="true" colors="true" + convertDeprecationsToExceptions="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" diff --git a/src/Browser.php b/src/Browser.php index 23bb1ed8..5d635f55 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -75,6 +75,10 @@ public function __construct(Connection $connection) // enable target discovery $connection->sendMessageSync(new Message('Target.setDiscoverTargets', ['discover' => true])); + + // set up http headers + $headers = $connection->getConnectionHttpHeaders(); + $connection->sendMessageSync(new Message('Network.setExtraHTTPHeaders', $headers)); } /** diff --git a/src/Browser/BrowserProcess.php b/src/Browser/BrowserProcess.php index d01520b6..e01347f5 100644 --- a/src/Browser/BrowserProcess.php +++ b/src/Browser/BrowserProcess.php @@ -150,6 +150,11 @@ public function start($binary, $options): void $connection->setConnectionDelay($options['connectionDelay']); } + // connection headers + if (\array_key_exists('headers', $options)) { + $connection->setConnectionHttpHeaders($options['headers']); + } + // set connection to allow killing chrome $this->connection = $connection; @@ -204,7 +209,7 @@ public function kill(): void $this->logger->debug('process: trying to close chrome gracefully'); $this->browser->sendCloseMessage(); } catch (\Exception $e) { - //log + // log $this->logger->debug('process: closing chrome gracefully - compatibility'); // close all pages if connected @@ -298,6 +303,7 @@ private function getArgsFromOptions($binary, array $options) '--disable-prompt-on-repost', '--disable-sync', '--disable-translate', + '--disable-features=ChromeWhatsNewUI', '--metrics-recording-only', '--no-first-run', '--safebrowsing-disable-auto-update', @@ -357,6 +363,12 @@ private function getArgsFromOptions($binary, array $options) if (\array_key_exists('proxyServer', $options)) { $args[] = '--proxy-server='.$options['proxyServer']; } + if (\array_key_exists('noProxyServer', $options) && $options['noProxyServer']) { + $args[] = '--no-proxy-server'; + } + if (\array_key_exists('proxyBypassList', $options)) { + $args[] = '--proxy-bypass-list='.$options['proxyBypassList']; + } // add custom flags if (\array_key_exists('customFlags', $options) && \is_array($options['customFlags'])) { diff --git a/src/BrowserFactory.php b/src/BrowserFactory.php index f0c90ea6..8155c688 100644 --- a/src/BrowserFactory.php +++ b/src/BrowserFactory.php @@ -11,11 +11,14 @@ namespace HeadlessChromium; -use Apix\Log\Logger\Stream as StreamLogger; use HeadlessChromium\Browser\BrowserProcess; use HeadlessChromium\Browser\ProcessAwareBrowser; use HeadlessChromium\Communication\Connection; use HeadlessChromium\Exception\BrowserConnectionFailed; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Process\Process; use Wrench\Exception\HandshakeException; @@ -23,6 +26,28 @@ class BrowserFactory { protected $chromeBinary; + /** + * Options for browser creation. + * + * - connectionDelay: Delay to apply between each operation for debugging purposes (default: none) + * - customFlags: An array of flags to pass to the command line. + * - debugLogger: A string (e.g "php://stdout"), or resource, or PSR-3 logger instance to print debug messages (default: none) + * - enableImages: Toggles loading of images (default: true) + * - envVariables: An array of environment variables to pass to the process (example DISPLAY variable) + * - headers: An array of custom HTTP headers + * - headless: Enable or disable headless mode (default: true) + * - ignoreCertificateErrors: Set chrome to ignore ssl errors + * - keepAlive: Set to `true` to keep alive the chrome instance when the script terminates (default: false) + * - noSandbox: Enable no sandbox mode, useful to run in a docker container (default: false) + * - proxyServer: Proxy server to use. ex: `127.0.0.1:8080` (default: none) + * - sendSyncDefaultTimeout: Default timeout (ms) for sending sync messages (default 5000 ms) + * - startupTimeout: Maximum time in seconds to wait for chrome to start (default: 30 sec) + * - userAgent: User agent to use for the whole browser + * - userDataDir: Chrome user data dir (default: a new empty dir is generated temporarily) + * - windowSize: Size of the window. ex: `[1920, 1080]` (default: none) + */ + protected $options = []; + public function __construct(string $chromeBinary = null) { $this->chromeBinary = $chromeBinary ?? (new AutoDiscover())->guessChromeBinaryPath(); @@ -31,27 +56,16 @@ public function __construct(string $chromeBinary = null) /** * Start a chrome process and allows to interact with it. * - * @param array $options options for browser creation: - * - connectionDelay: amount of time in seconds to slows down connection for debugging purposes (default: none) - * - customFlags: array of custom flag to flags to pass to the command line - * - debugLogger: resource string ("php://stdout"), resource or psr-3 logger instance (default: none) - * - enableImages: toggle the loading of images (default: true) - * - envVariables: array of environment variables to pass to the process (example DISPLAY variable) - * - headless: whether chrome should be started headless (default: true) - * - ignoreCertificateErrors: set chrome to ignore ssl errors - * - keepAlive: true to keep alive the chrome instance when the script terminates (default: false) - * - noSandbox: enable no sandbox mode (default: false) - * - proxyServer: a proxy server, ex: 127.0.0.1:8080 (default: none) - * - sendSyncDefaultTimeout: maximum time in ms to wait for synchronous messages to send (default 5000 ms) - * - startupTimeout: maximum time in seconds to wait for chrome to start (default: 30 sec) - * - userAgent: user agent to use for the browser - * - userDataDir: chrome user data dir (default: a new empty dir is generated temporarily) - * - windowSize: size of the window, ex: [1920, 1080] (default: none) + * @see BrowserFactory::$options + * + * @param array|null $options overwrite options for browser creation * * @return ProcessAwareBrowser a Browser instance to interact with the new chrome process */ - public function createBrowser(array $options = []): ProcessAwareBrowser + public function createBrowser(?array $options = null): ProcessAwareBrowser { + $options = $options ?? $this->options; + // create logger from options $logger = self::createLogger($options); @@ -69,6 +83,21 @@ public function createBrowser(array $options = []): ProcessAwareBrowser return $browserProcess->getBrowser(); } + public function addHeader(string $name, string $value): void + { + $this->options['headers'][$name] = $value; + } + + /** + * @param array $headers + */ + public function addHeaders(array $headers): void + { + foreach ($headers as $name => $value) { + $this->addHeader($name, $value); + } + } + /** * Connects to an existing browser using it's web socket uri. * @@ -125,22 +154,48 @@ public static function connectToBrowser(string $uri, array $options = []): Brows } /** - * Create a logger instance from given options. + * Set default options to be used in all browser instances. * - * @param array $options + * @see BrowserFactory::$options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } + + /** + * Add or overwrite options to the default options list. * - * @return StreamLogger|null + * @see BrowserFactory::$options + */ + public function addOptions(array $options): void + { + $this->options = \array_merge($this->options, $options); + } + + public function getOptions(): array + { + return $this->options; + } + + /** + * Create a logger instance from given options. */ - private static function createLogger($options) + private static function createLogger(array $options): LoggerInterface { - // prepare logger $logger = $options['debugLogger'] ?? null; - // create logger from string name or resource + if ($logger instanceof LoggerInterface) { + return $logger; + } + if (\is_string($logger) || \is_resource($logger)) { - $logger = new StreamLogger($logger); + $log = new Logger('chrome'); + $log->pushHandler(new StreamHandler($logger)); + + return $log; } - return $logger; + return new NullLogger(); } } diff --git a/src/Communication/Connection.php b/src/Communication/Connection.php index d5faff7b..c42accfb 100644 --- a/src/Communication/Connection.php +++ b/src/Communication/Connection.php @@ -85,6 +85,11 @@ class Connection extends EventEmitter implements LoggerAwareInterface */ protected $receivedData = []; + /** + * @var array + */ + protected $httpHeaders = []; + /** * CommunicationChannel constructor. * @@ -127,6 +132,24 @@ public function setConnectionDelay(int $delay): void $this->delay = $delay; } + /** + * @param array $headers + * + * @return void + */ + public function setConnectionHttpHeaders(array $headers): void + { + $this->httpHeaders = $headers; + } + + /** + * @return array + */ + public function getConnectionHttpHeaders(): array + { + return $this->httpHeaders; + } + /** * Gets the default timeout used when sending a message synchronously. * @@ -189,7 +212,7 @@ public function isConnected() private function waitForDelay(): void { if ($this->lastMessageSentTime) { - $currentTime = (int) (\microtime(true) * 1000); + $currentTime = (int) (\hrtime(true) / 1000 / 1000); // if not enough time was spent until last message was sent, wait if ($this->lastMessageSentTime + $this->delay > $currentTime) { $timeToWait = ($this->lastMessageSentTime + $this->delay) - $currentTime; @@ -197,7 +220,7 @@ private function waitForDelay(): void } } - $this->lastMessageSentTime = (int) (\microtime(true) * 1000); + $this->lastMessageSentTime = (int) (\hrtime(true) / 1000 / 1000); } /** diff --git a/src/Communication/Response.php b/src/Communication/Response.php index 9129e90a..a72ab13e 100644 --- a/src/Communication/Response.php +++ b/src/Communication/Response.php @@ -101,6 +101,7 @@ public function getData(): array /** * {@inheritdoc} */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { return \array_key_exists($offset, $this->data); @@ -109,6 +110,7 @@ public function offsetExists($offset) /** * {@inheritdoc} */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->data[$offset]; diff --git a/src/Communication/Session.php b/src/Communication/Session.php index 28dec10e..d2d086b1 100644 --- a/src/Communication/Session.php +++ b/src/Communication/Session.php @@ -135,6 +135,7 @@ public function destroy(): void } $this->emit('destroyed'); $this->connection = null; + $this->destroyed = true; $this->removeAllListeners(); } } diff --git a/src/Cookies/Cookie.php b/src/Cookies/Cookie.php index 37651d81..046fd687 100644 --- a/src/Cookies/Cookie.php +++ b/src/Cookies/Cookie.php @@ -57,6 +57,7 @@ public function getDomain() /** * {@inheritdoc} */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { return \array_key_exists($offset, $this->data); @@ -65,6 +66,7 @@ public function offsetExists($offset) /** * {@inheritdoc} */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->data[$offset] ?? null; diff --git a/src/Cookies/CookiesCollection.php b/src/Cookies/CookiesCollection.php index fcc1ca6f..23ba52c8 100644 --- a/src/Cookies/CookiesCollection.php +++ b/src/Cookies/CookiesCollection.php @@ -44,6 +44,7 @@ public function addCookie(Cookie $cookie): void /** * {@inheritdoc} */ + #[\ReturnTypeWillChange] public function getIterator() { return new \ArrayIterator($this->cookies); @@ -52,6 +53,7 @@ public function getIterator() /** * {@inheritdoc} */ + #[\ReturnTypeWillChange] public function count() { return \count($this->cookies); diff --git a/src/Dom/Node.php b/src/Dom/Node.php index d284750f..fb336245 100644 --- a/src/Dom/Node.php +++ b/src/Dom/Node.php @@ -64,7 +64,7 @@ public function querySelector(string $selector): ?self $nodeId = $response->getResultData('nodeId'); - if (null !== $nodeId) { + if (null !== $nodeId && 0 !== $nodeId) { return new self($this->page, $nodeId); } @@ -179,9 +179,14 @@ public function sendKeys(string $text): void } public function sendFile(string $filePath): void + { + $this->sendFiles([$filePath]); + } + + public function sendFiles(array $filePaths): void { $message = new Message('DOM.setFileInputFiles', [ - 'files' => [$filePath], + 'files' => $filePaths, 'nodeId' => $this->nodeId, ]); $response = $this->page->getSession()->sendMessageSync($message); diff --git a/src/Exception/OperationTimedOut.php b/src/Exception/OperationTimedOut.php index 73b569c7..8e4e5bc0 100644 --- a/src/Exception/OperationTimedOut.php +++ b/src/Exception/OperationTimedOut.php @@ -13,4 +13,21 @@ class OperationTimedOut extends \Exception { + public static function createFromTimeout(int $timeoutMicroSec): self + { + return new self(\sprintf('Operation timed out after %s.', self::getTimeoutPhrase($timeoutMicroSec))); + } + + private static function getTimeoutPhrase(int $timeoutMicroSec): string + { + if ($timeoutMicroSec > 1000 * 1000) { + return \sprintf('%ds', (int) ($timeoutMicroSec / (1000 * 1000))); + } + + if ($timeoutMicroSec > 1000) { + return \sprintf('%dms', (int) ($timeoutMicroSec / 1000)); + } + + return \sprintf('%dμs', (int) ($timeoutMicroSec)); + } } diff --git a/src/Input/Keyboard.php b/src/Input/Keyboard.php index e672e80e..766f82d6 100644 --- a/src/Input/Keyboard.php +++ b/src/Input/Keyboard.php @@ -50,13 +50,13 @@ public function typeText(string $text) { $this->page->assertNotClosed(); - $length = \strlen($text); + $length = \mb_strlen($text); for ($i = 0; $i < $length; ++$i) { $this->page->getSession()->sendMessageSync(new Message('Input.dispatchKeyEvent', [ 'type' => 'char', 'modifiers' => $this->getModifiers(), - 'text' => $text[$i], + 'text' => \mb_substr($text, $i, 1), ])); \usleep($this->sleep); diff --git a/src/Input/Mouse.php b/src/Input/Mouse.php index 663de90e..6f50dd76 100644 --- a/src/Input/Mouse.php +++ b/src/Input/Mouse.php @@ -178,11 +178,11 @@ private function scroll(int $distanceY, int $distanceX = 0): self { $this->page->assertNotClosed(); - $scollableArea = $this->page->getLayoutMetrics()->getContentSize(); - $visibleArea = $this->page->getLayoutMetrics()->getVisualViewport(); + $scrollableArea = $this->page->getLayoutMetrics()->getCssContentSize(); + $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); - $distanceX = $this->getMaximumDistance($distanceX, $visibleArea['pageX'], $scollableArea['width']); - $distanceY = $this->getMaximumDistance($distanceY, $visibleArea['pageY'], $scollableArea['height']); + $distanceX = $this->getMaximumDistance($distanceX, $visibleArea['pageX'], $scrollableArea['width']); + $distanceY = $this->getMaximumDistance($distanceY, $visibleArea['pageY'], $scrollableArea['height']); $targetX = $visibleArea['pageX'] + $distanceX; $targetY = $visibleArea['pageY'] + $distanceY; @@ -222,7 +222,7 @@ private function scroll(int $distanceY, int $distanceX = 0): self */ private function scrollToBoundary(int $right, int $bottom): self { - $visibleArea = $this->page->getLayoutMetrics()->getLayoutViewport(); + $visibleArea = $this->page->getLayoutMetrics()->getCssLayoutViewport(); $distanceX = $distanceY = 0; @@ -285,14 +285,16 @@ public function find(string $selectors, int $position = 1): self $rightBoundary = \floor($element['right']); $bottomBoundary = \floor($element['bottom']); - $positionX = \mt_rand(\ceil($element['left']), $rightBoundary); - $positionY = \mt_rand(\ceil($element['top']), $bottomBoundary); + $this->scrollToBoundary($rightBoundary, $bottomBoundary); + + $visibleArea = $this->page->getLayoutMetrics()->getLayoutViewport(); + + $offsetX = $visibleArea['pageX']; + $offsetY = $visibleArea['pageY']; + $positionX = \random_int(\ceil($element['left'] - $offsetX), $rightBoundary - $offsetX); + $positionY = \random_int(\ceil($element['top'] - $offsetY), $bottomBoundary - $offsetY); - $this->scrollToBoundary($rightBoundary, $bottomBoundary) - ->move( - ($positionX - $this->x), - ($positionY - $this->y) - ); + $this->move($positionX, $positionY); return $this; } @@ -338,7 +340,7 @@ private function getMaximumDistance(int $distance, int $current, int $maximum): private function waitForScroll(int $targetX, int $targetY) { while (true) { - $visibleArea = $this->page->getLayoutMetrics()->getVisualViewport(); + $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); if ($visibleArea['pageX'] === $targetX && $visibleArea['pageY'] === $targetY) { return true; diff --git a/src/Page.php b/src/Page.php index 2b590570..3cee22ad 100644 --- a/src/Page.php +++ b/src/Page.php @@ -148,10 +148,9 @@ public function getSession(): Session public function setBasicAuthHeader(string $username, string $password): void { $header = \base64_encode($username.':'.$password); - $this->getSession()->sendMessage(new Message( - 'Network.setExtraHTTPHeaders', - ['headers' => ['Authorization' => 'Basic '.$header]] - )); + $this->setExtraHTTPHeaders([ + 'Authorization' => 'Basic '.$header, + ]); } /** @@ -167,6 +166,23 @@ public function setDownloadPath(string $path): void )); } + /** + * Set extra http headers. + * + * If headers are not passed, all instances of Page::class will use global settings from the BrowserFactory::class + * + * @see https://chromedevtools.github.io/devtools-protocol/1-2/Network/#method-setExtraHTTPHeaders + * + * @param array $headers + */ + public function setExtraHTTPHeaders(array $headers = []): void + { + $this->getSession()->sendMessage(new Message( + 'Network.setExtraHTTPHeaders', + $headers + )); + } + /** * @param string $url * @param array $options @@ -425,11 +441,11 @@ private function waitForReloadGenerator($eventName, $loaderId) } /** - * Get a clip that uses the full layout page, not only the viewport. + * Get a clip that uses the full screen layout (only the viewport). * - * This method is synchronous + * This method is synchronous. * - * Fullpage screenshot exemple: + * Full-screen screenshot example: * * ```php * $page @@ -445,7 +461,7 @@ private function waitForReloadGenerator($eventName, $loaderId) */ public function getFullPageClip(int $timeout = null): Clip { - $contentSize = $this->getLayoutMetrics()->await($timeout)->getContentSize(); + $contentSize = $this->getLayoutMetrics()->await($timeout)->getCssContentSize(); return new Clip(0, 0, $contentSize['width'], $contentSize['height']); } @@ -453,11 +469,59 @@ public function getFullPageClip(int $timeout = null): Clip /** * Take a screenshot. * - * Usage: + * Simple screenshot: * * ```php * $page->screenshot()->saveToFile('/tmp/image.jpg'); * ``` + * -------------------------------------------------------------------------------- + * + * Screenshot an area on a page: + * + * ```php + * use HeadlessChromium\Clip; + * + * // navigate + * $navigation = $page->navigate('http://example.com'); + * + * // wait for the page to be loaded + * $navigation->waitForNavigation(); + * + * // create a rectangle by specifying to left corner coordinates + width and height + * $x = 10; + * $y = 10; + * $width = 100; + * $height = 100; + * $clip = new Clip($x, $y, $width, $height); + * + * // take the screenshot (in memory binaries) + * $screenshot = $page->screenshot([ + * 'clip' => $clip, + * ]); + * + * // save the screenshot + * $screenshot->saveToFile('/some/place/file.jpg'); + * ``` + * -------------------------------------------------------------------------------- + * + * Full-page screenshot (not only the viewport): + * + * ```php + * // navigate + * $navigation = $page->navigate('https://example.com'); + * + * // wait for the page to be loaded + * $navigation->waitForNavigation(); + * + * $screenshot = $page->screenshot([ + * 'captureBeyondViewport' => true, + * 'clip' => $page->getFullPageClip(), + * 'format' => 'jpeg', // default to 'png' - possible values: 'png', 'jpeg', + * ]); + * + * // save the screenshot + * $screenshot->saveToFile('/some/place/file.jpg'); + * ``` * * @param array $options * - format: "png"|"jpg" default "png" @@ -748,9 +812,9 @@ public function getCurrentUrl() * * @return string */ - public function getHtml() + public function getHtml(?int $timeout = null): string { - return $this->evaluate('document.documentElement.outerHTML')->getReturnValue(); + return $this->evaluate('document.documentElement.outerHTML')->getReturnValue($timeout); } /** diff --git a/src/PageUtils/PageLayoutMetrics.php b/src/PageUtils/PageLayoutMetrics.php index a8718398..3325ea32 100644 --- a/src/PageUtils/PageLayoutMetrics.php +++ b/src/PageUtils/PageLayoutMetrics.php @@ -47,9 +47,7 @@ public function getMetrics(): array */ public function getContentSize(): array { - $response = $this->awaitResponse(); - - return $response->getResultData('contentSize'); + return $this->getResultData('contentSize'); } /** @@ -63,9 +61,7 @@ public function getContentSize(): array */ public function getLayoutViewport(): array { - $response = $this->awaitResponse(); - - return $response->getResultData('layoutViewport'); + return $this->getResultData('layoutViewport'); } /** @@ -79,8 +75,54 @@ public function getLayoutViewport(): array */ public function getVisualViewport() { - $response = $this->awaitResponse(); + return $this->getResultData('visualViewport'); + } + + /** + * Returns real size of scrollable area. + * + * @throws CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + * + * @return array + */ + public function getCssContentSize(): array + { + return $this->getResultData('cssContentSize') ?? $this->getContentSize(); + } + + /** + * Returns real metrics relating to the layout viewport. + * + * @throws CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + * + * @return array + */ + public function getCssLayoutViewport(): array + { + return $this->getResultData('cssLayoutViewport') ?? $this->getLayoutViewport(); + } + + /** + * Returns real metrics relating to the visual viewport. + * + * @throws CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + * + * @return array + */ + public function getCssVisualViewport() + { + return $this->getResultData('cssVisualViewport') ?? $this->getVisualViewport(); + } - return $response->getResultData('visualViewport'); + /** @param 'layoutViewport'|'visualViewport'|'contentSize'|'cssLayoutViewport'|'cssVisualViewport'|'cssContentSize' $key */ + private function getResultData(string $key): array + { + return $this->awaitResponse()->getResultData($key); } } diff --git a/src/Utils.php b/src/Utils.php index c2121410..b97887f9 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -52,27 +52,19 @@ class Utils */ public static function tryWithTimeout(int $timeoutMicroSec, \Generator $generator, callable $onTimeout = null) { - $waitUntilMicroSec = \microtime(true) * 1000 * 1000 + $timeoutMicroSec; + $waitUntilMicroSec = \hrtime(true) / 1000 + $timeoutMicroSec; foreach ($generator as $v) { // if timeout reached or if time+delay exceed timeout stop the execution - if (\microtime(true) * 1000 * 1000 + $v >= $waitUntilMicroSec) { - if ($onTimeout) { + if (\hrtime(true) / 1000 + (int) $v >= $waitUntilMicroSec) { + if (null !== $onTimeout) { // if callback was set execute it return $onTimeout(); - } else { - if ($timeoutMicroSec > 1000 * 1000) { - $timeoutPhrase = (int) ($timeoutMicroSec / (1000 * 1000)).'sec'; - } elseif ($timeoutMicroSec > 1000) { - $timeoutPhrase = (int) ($timeoutMicroSec / 1000).'ms'; - } else { - $timeoutPhrase = (int) ($timeoutMicroSec).'μs'; - } - throw new OperationTimedOut('Operation timed out ('.$timeoutPhrase.')'); } + throw OperationTimedOut::createFromTimeout($timeoutMicroSec); } - \usleep($v); + \usleep((int) $v); } return $generator->getReturn(); diff --git a/tests/BrowserFactoryTest.php b/tests/BrowserFactoryTest.php index 4843334d..9098ca7c 100644 --- a/tests/BrowserFactoryTest.php +++ b/tests/BrowserFactoryTest.php @@ -26,7 +26,7 @@ public function testBrowserFactory(): void $browser = $factory->createBrowser(); - $this->assertRegExp('#^ws://#', $browser->getSocketUri()); + $this->assertMatchesRegularExpression('#^ws://#', $browser->getSocketUri()); } public function testWindowSizeOption(): void @@ -59,6 +59,53 @@ public function testUserAgentOption(): void $this->assertEquals('foo bar baz', $response); } + public function testAddHeaders(): void + { + $factory = new BrowserFactory(); + + $factory->addHeader('header_name', 'header_value'); + $factory->addHeaders(['header_name2' => 'header_value2']); + + $expected = [ + 'header_name' => 'header_value', + 'header_name2' => 'header_value2', + ]; + + $this->assertSame($expected, $factory->getOptions()['headers']); + } + + public function testOptions(): void + { + $factory = new BrowserFactory(); + + $headers = ['header_name' => 'header_value']; + $options = ['userAgent' => 'foo bar baz']; + $modifiedOptions = ['userAgent' => 'foo bar']; + + $factory->addHeaders($headers); + $factory->addOptions($options); + + $expected = \array_merge(['headers' => $headers], $options); + + $this->assertSame($expected, $factory->getOptions()); + + // test overwriting + $factory->addOptions($modifiedOptions); + + $expected['userAgent'] = 'foo bar'; + + $this->assertSame($expected, $factory->getOptions()); + + // test removing options + $factory->setOptions($modifiedOptions); + + $this->assertSame($modifiedOptions, $factory->getOptions()); + + $factory->setOptions([]); + + $this->assertSame([], $factory->getOptions()); + } + public function testConnectToBrowser(): void { // create a browser diff --git a/tests/Communication/ConnectionTest.php b/tests/Communication/ConnectionTest.php index eeb26b39..bafa66b7 100644 --- a/tests/Communication/ConnectionTest.php +++ b/tests/Communication/ConnectionTest.php @@ -132,6 +132,19 @@ public function testSendMessageWorksWithDelay(): void ); } + public function testConnectionHttpHeaders(): void + { + $connection = new Connection($this->mocSocket); + + $header = [ + 'header_name' => 'header_value', + ]; + + $connection->setConnectionHttpHeaders($header); + + $this->assertSame($header, $connection->getConnectionHttpHeaders()); + } + public function testSendMessageSync(): void { $connection = new Connection($this->mocSocket); @@ -196,7 +209,7 @@ public function testExceptionInvalideJson(): void $connection = new Connection($this->mocSocket); $connection->connect(); - //set invalid json + // set invalid json $this->mocSocket->addReceivedData('{'); $connection->readData(); @@ -209,7 +222,7 @@ public function testExceptionInvalideArrayResponse(): void $connection = new Connection($this->mocSocket); $connection->connect(); - //set string variable instead of array + // set string variable instead of array $this->mocSocket->addReceivedData('"foo"'); $connection->readData(); @@ -222,7 +235,7 @@ public function testInvalidResponseId(): void $connection = new Connection($this->mocSocket); $connection->connect(); - //set string variable instead of array + // set string variable instead of array $this->mocSocket->addReceivedData('{"message": "foo"}'); $connection->readData(); diff --git a/tests/DomTest.php b/tests/DomTest.php index 232174bf..10a7cafd 100644 --- a/tests/DomTest.php +++ b/tests/DomTest.php @@ -40,8 +40,10 @@ public function testSearchByCssSelector(): void { $page = $this->openSitePage('domForm.html'); $element = $page->dom()->querySelector('button'); + $notFoundElement = $page->dom()->querySelector('img'); $this->assertNotNull($element); + $this->assertNull($notFoundElement); } public function testSearchByCssSelectorAll(): void @@ -51,6 +53,9 @@ public function testSearchByCssSelectorAll(): void $elements = $page->dom()->querySelectorAll('div'); $this->assertCount(2, $elements); + + $notFoundElements = $page->dom()->querySelectorAll('img'); + $this->assertCount(0, $notFoundElements); } public function testSearchByXpath(): void @@ -149,7 +154,26 @@ public function testUploadFile(): void ->evaluate('document.querySelector("#myfile").value;') ->getReturnValue(); - // check if file was uploaded - $this->assertNotEmpty($value); + // check if the file was selected + $this->assertStringEndsWith(\basename($file), $value); + } + + public function testUploadFiles(): void + { + $page = $this->openSitePage('domForm.html'); + $files = [ + self::sitePath('domForm.html'), + self::sitePath('form.html'), + ]; + + $element = $page->dom()->querySelector('#myfiles'); + $element->sendFiles($files); + + $value1 = $page->evaluate('document.querySelector("#myfiles").files[0].name;')->getReturnValue(); + $value2 = $page->evaluate('document.querySelector("#myfiles").files[1].name;')->getReturnValue(); + + // check if the files were selected + $this->assertStringEndsWith(\basename($files[0]), $value1); + $this->assertStringEndsWith(\basename($files[1]), $value2); } } diff --git a/tests/HttpEnabledTestCase.php b/tests/HttpEnabledTestCase.php index 9a6e2092..823a7533 100644 --- a/tests/HttpEnabledTestCase.php +++ b/tests/HttpEnabledTestCase.php @@ -30,7 +30,7 @@ public static function setUpBeforeClass(): void __DIR__.'/resources/static-web', ]); self::$process->start(); - \usleep(80000); //wait for server to get going + \usleep(80000); // wait for server to get going // ensure it started if (!self::$process->isRunning()) { diff --git a/tests/KeyboardApiTest.php b/tests/KeyboardApiTest.php index 0f817788..fd770e31 100644 --- a/tests/KeyboardApiTest.php +++ b/tests/KeyboardApiTest.php @@ -157,16 +157,40 @@ public function testKeyInterval(): void // initial navigation $page = $this->openSitePage('form.html'); - $start = \round(\microtime(true) * 1000); + $start = \round(\hrtime(true) / 1000 / 1000); $page->keyboard() ->setKeyInterval(100) ->typeRawKey('Tab') ->typeText('bar'); - $millisecondsElapsed = \round(\microtime(true) * 1000) - $start; + $millisecondsElapsed = \round(\hrtime(true) / 1000 / 1000) - $start; // if this test takes less than 300ms to run (3 keys x 100ms), setKeyInterval is not working $this->assertGreaterThan(300, $millisecondsElapsed); } + + /** + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\CommunicationException\InvalidResponse + */ + public function testTypeUnicodeText(): void + { + // initial navigation + $page = $this->openSitePage('form.html'); + + $text = 'Со ГӀалгӀа ва'; + + $page->keyboard() + ->type('Tab') + ->typeText($text); + + $value = $page + ->evaluate('document.querySelector("#myinput").value;') + ->getReturnValue(); + + // checks if the input contains the typed text + $this->assertSame($text, $value); + } } diff --git a/tests/MouseApiTest.php b/tests/MouseApiTest.php index 93895574..8fca0b04 100644 --- a/tests/MouseApiTest.php +++ b/tests/MouseApiTest.php @@ -68,7 +68,7 @@ public function testClickLink(): void ->evaluate('JSON.parse(JSON.stringify(document.querySelector("#a").getBoundingClientRect()));') ->getReturnValue(); - $page->mouse()->move($rect['x'], $rect['y'])->click(); + $page->mouse()->move(\ceil($rect['x']), \ceil($rect['y']))->click(); $page->waitForReload(); $title = $page->evaluate('document.title')->getReturnValue(); @@ -117,6 +117,25 @@ public function testFind_withSingleElement(): void $this->assertEquals('a - test', $title); } + /** + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + */ + public function testFind_afterMove(): void + { + // initial navigation + $page = $this->openSitePage('b.html'); + + $page->mouse()->move(1000, 1000); + + $page->mouse()->find('#a')->click(); + $page->waitForReload(); + + $title = $page->evaluate('document.title')->getReturnValue(); + + $this->assertEquals('a - test', $title); + } + /** * @dataProvider mouseFindProvider * diff --git a/tests/PageTest.php b/tests/PageTest.php index f7670e67..68609388 100644 --- a/tests/PageTest.php +++ b/tests/PageTest.php @@ -263,6 +263,9 @@ public function testGetLayoutMetrics(): void $contentSize = $metrics->getContentSize(); $layoutViewport = $metrics->getLayoutViewport(); $visualViewport = $metrics->getVisualViewport(); + $cssContentSize = $metrics->getCssContentSize(); + $cssLayoutViewport = $metrics->getCssLayoutViewport(); + $cssVisualViewport = $metrics->getCssVisualViewport(); $this->assertEquals( [ @@ -297,6 +300,70 @@ public function testGetLayoutMetrics(): void ], $visualViewport ); + + // This is made to be a bit loose to pass on retina displays + + $this->assertContains( + $cssContentSize, + [ + [ + 'x' => 0, + 'y' => 0, + 'width' => 900, + 'height' => 1000, + ], + [ + 'x' => 0, + 'y' => 0, + 'width' => 1800, + 'height' => 2000, + ], + ] + ); + + $this->assertContains( + $cssLayoutViewport, + [ + [ + 'pageX' => 0, + 'pageY' => 0, + 'clientWidth' => 100, + 'clientHeight' => 300, + ], + [ + 'pageX' => 0, + 'pageY' => 0, + 'clientWidth' => 200, + 'clientHeight' => 600, + ], + ] + ); + + $this->assertContains( + $cssVisualViewport, + [ + [ + 'offsetX' => 0, + 'offsetY' => 0, + 'pageX' => 0, + 'pageY' => 0, + 'clientWidth' => 100, + 'clientHeight' => 300, + 'scale' => 1, + 'zoom' => 1, + ], + [ + 'offsetX' => 0, + 'offsetY' => 0, + 'pageX' => 0, + 'pageY' => 0, + 'clientWidth' => 200, + 'clientHeight' => 600, + 'scale' => 1, + 'zoom' => 1, + ], + ] + ); } public function testGetFullPageClip(): void diff --git a/tests/resources/static-web/bigLayout.html b/tests/resources/static-web/bigLayout.html index da910030..7189c98e 100644 --- a/tests/resources/static-web/bigLayout.html +++ b/tests/resources/static-web/bigLayout.html @@ -12,8 +12,6 @@

page b

b
-go to a - diff --git a/tests/resources/static-web/domForm.html b/tests/resources/static-web/domForm.html index ea80b0d7..7eff87f1 100644 --- a/tests/resources/static-web/domForm.html +++ b/tests/resources/static-web/domForm.html @@ -10,6 +10,7 @@

Form

+
diff --git a/vendor-bin/phpstan/composer.json b/vendor-bin/phpstan/composer.json index ddf7edb3..6de6da3d 100644 --- a/vendor-bin/phpstan/composer.json +++ b/vendor-bin/phpstan/composer.json @@ -1,7 +1,7 @@ { "require": { - "php": "^7.3", - "phpstan/phpstan": "1.2.0" + "php": "^7.4", + "phpstan/phpstan": "1.5.0" }, "config": { "preferred-install": "dist"