dependencies-upgrade

This commit is contained in:
RafficMohammed
2023-01-08 02:20:59 +05:30
parent 7870479b18
commit 49021a4497
1711 changed files with 74994 additions and 70803 deletions

View File

@@ -1,240 +0,0 @@
# Release Notes
## [Unreleased](https://github.com/laravel/dusk/compare/v5.11.0...5.0)
## [v5.11.0 (2020-03-24)](https://github.com/laravel/dusk/compare/v5.10.0...v5.11.0)
### Added
- Add assert attribute methods ([#751](https://github.com/laravel/dusk/pull/751))
### Fixed
- Fix Call to undefined method ([#750](https://github.com/laravel/dusk/pull/750))
- Avoid throwing exception on production ([#755](https://github.com/laravel/dusk/pull/755))
## [v5.10.0 (2020-03-17)](https://github.com/laravel/dusk/compare/v5.9.2...v5.10.0)
### Added
- Adds `typeSlowly` & `appendSlowly` ([#748](https://github.com/laravel/dusk/pull/748))
## [v5.9.2 (2020-02-18)](https://github.com/laravel/dusk/compare/v5.9.1...v5.9.2)
### Fixed
- Bugfix quoting for `InteractsWithElements::value` ([#735](https://github.com/laravel/dusk/pull/735))
- Remove php-webdriver constraints ([#737](https://github.com/laravel/dusk/pull/737))
## [v5.9.1 (2020-02-12)](https://github.com/laravel/dusk/compare/v5.9.0...v5.9.1)
### Fixed
- Adds the missing import for `InteractsWithMouse@clickAtXPath` ([#728](https://github.com/laravel/dusk/pull/728))
- Size sanity check at fitContent ([#730](https://github.com/laravel/dusk/pull/730))
- Lock php-webdriver constraints for now ([#733](https://github.com/laravel/dusk/pull/733))
## [v5.9.0 (2020-01-28)](https://github.com/laravel/dusk/compare/v5.8.2...v5.9.0)
### Added
- Add `clickAtXPath` ([#723](https://github.com/laravel/dusk/pull/723), [effe73d](https://github.com/laravel/dusk/commit/effe73d6eb61b4bd77f88814bcd679e4fceb6f25))
- Add `ProvidesBrowser::getCallerName()` ([#725](https://github.com/laravel/dusk/pull/725))
### Fixed
- Fit content to `<html>` instead of `<body>` ([#726](https://github.com/laravel/dusk/pull/726))
## [v5.8.2 (2020-01-21)](https://github.com/laravel/dusk/compare/v5.8.1...v5.8.2)
### Changed
- Rename php-webdriver package ([#720](https://github.com/laravel/dusk/pull/720))
- Update jQuery file ([#721](https://github.com/laravel/dusk/pull/721))
### Fixed
- Update `fitContent()` ([#717](https://github.com/laravel/dusk/pull/717))
## [v5.8.1 (2020-01-07)](https://github.com/laravel/dusk/compare/v5.8.0...v5.8.1)
### Fixed
- Cast boolean values to appropriate string ([#713](https://github.com/laravel/dusk/pull/713))
## [v5.8.0 (2019-12-30)](https://github.com/laravel/dusk/compare/v5.7.0...v5.8.0)
### Added
- Add "waitUntilMissingText" ([#706](https://github.com/laravel/dusk/pull/706))
- Add ability to store source from browser ([#707](https://github.com/laravel/dusk/pull/707), [9c90e2a](https://github.com/laravel/dusk/commit/9c90e2a716030c9a36e6306c3f67d606a254bbb7), [1d5bc20](https://github.com/laravel/dusk/commit/1d5bc203b67ffc5a17eb1b89f3e22547e3ea174b))
## [v5.7.0 (2019-12-17)](https://github.com/laravel/dusk/compare/v5.6.3...v5.7.0)
### Added
- Automatically fit content on failures ([#704](https://github.com/laravel/dusk/pull/704))
## [v5.6.3 (2019-12-03)](https://github.com/laravel/dusk/compare/v5.6.2...v5.6.3)
### Added
- Support phpdotenv v4 ([#699](https://github.com/laravel/dusk/pull/699))
### Fixed
- scrollTo: add support for selectors with quotes ([#697](https://github.com/laravel/dusk/pull/697))
## [v5.6.2 (2019-11-26)](https://github.com/laravel/dusk/compare/v5.6.1...v5.6.2)
### Changed
- Allow for Symfony 5 ([#696](https://github.com/laravel/dusk/pull/696))
## [v5.6.1 (2019-11-12)](https://github.com/laravel/dusk/compare/v5.6.0...v5.6.1)
### Fixed
- Ensure jQuery for scrollTo ([#686](https://github.com/laravel/dusk/pull/686))
- Added missing return statement in withDuskEnvironment ([#691](https://github.com/laravel/dusk/pull/691))
- Prevent using pcntl when not installed ([#692](https://github.com/laravel/dusk/pull/692))
## [v5.6.0 (2019-10-29)](https://github.com/laravel/dusk/compare/v5.5.2...v5.6.0)
### Added
- Add scrollTo method ([#684](https://github.com/laravel/dusk/pull/684))
### Fixed
- Add graceful handler for `SIGINT` for .env restoration ([#682](https://github.com/laravel/dusk/pull/682), [f843b8a](https://github.com/laravel/dusk/commit/f843b8a51ae96933cefcc74dec515377d3135611))
## [v5.5.2 (2019-09-24)](https://github.com/laravel/dusk/compare/v5.5.1...v5.5.2)
### Fixed
- Improve detection of latest stable ChromeDriver release ([#677](https://github.com/laravel/dusk/pull/677))
## [v5.5.1 (2019-09-12)](https://github.com/laravel/dusk/compare/v5.5.0...v5.5.1)
### Fixed
- Update regular expression base on website changes ([#674](https://github.com/laravel/dusk/pull/674))
## [v5.5.0 (2019-08-06)](https://github.com/laravel/dusk/compare/v5.4.0...v5.5.0)
### Added
- Allow saving screenshots in a subdirectory ([#662](https://github.com/laravel/dusk/pull/662))
## [v5.4.0 (2019-07-30)](https://github.com/laravel/dusk/compare/v5.3.0...v5.4.0)
### Added
- Add assertion checks if a button is disabled or enabled ([#661](https://github.com/laravel/dusk/pull/661))
### Fixed
- Update constraints for Laravel 6.0 ([e4b4d63](https://github.com/laravel/dusk/commit/e4b4d63c179bb1f228db22852bd776db750d1ec6))
## [v5.3.0 (2019-07-11)](https://github.com/laravel/dusk/compare/v5.2.0...v5.3.0)
### Added
- Add proxy support to the `dusk:install` command ([#659](https://github.com/laravel/dusk/pull/659))
## [v5.2.0 (2019-06-25)](https://github.com/laravel/dusk/compare/v5.1.1...v5.2.0)
### Added
- Add option to fullsize the browser ([#655](https://github.com/laravel/dusk/pull/655))
## [v5.1.1 (2019-06-14)](https://github.com/laravel/dusk/compare/v5.1.0...v5.1.1)
### Fixed
- Add latest version of Facebook Webdriver ([#654](https://github.com/laravel/dusk/pull/654))
## [v5.1.0 (2019-05-02)](https://github.com/laravel/dusk/compare/v5.0.3...v5.1.0)
### Added
- Implement ChromeDriverCommand ([#643](https://github.com/laravel/dusk/pull/643), [60339a5](https://github.com/laravel/dusk/commit/60339a521a1b05e55af7c90b3151557100a0db4d), [#644](https://github.com/laravel/dusk/pull/644))
## [v5.0.3 (2019-04-02)](https://github.com/laravel/dusk/compare/v5.0.2...v5.0.3)
### Fixed
- Fix `assertVueContains` and `assertVueDoesNotContain` ([#638](https://github.com/laravel/dusk/pull/638))
## [v5.0.2 (2019-03-12)](https://github.com/laravel/dusk/compare/v5.0.1...v5.0.2)
### Fixed
- Fix cookies with falsey values ([#617](https://github.com/laravel/dusk/pull/617))
- Fix `with()` and page assertions ([#625](https://github.com/laravel/dusk/pull/625))
- Avoid deprecation messages on PHPUnit 8 ([#620](https://github.com/laravel/dusk/pull/620))
## [v5.0.1 (2019-02-27)](https://github.com/laravel/dusk/compare/v5.0.0...v5.0.1)
### Added
- Added support for `phpunit.dusk.xml.dist` ([#610](https://github.com/laravel/dusk/pull/610))
- Use custom DISPLAY variable when set on Linux ([#613](https://github.com/laravel/dusk/pull/613), [2480495](https://github.com/laravel/dusk/commit/24804953c5b521415a717afbf82ff4b938c10535))
### Fixed
- Added missing dependencies ([98eccfd](https://github.com/laravel/dusk/commit/98eccfd56e9b2b23b093b801f62c766aaf67589f))
- Fix installation of Dotenv on Laravel 5.8 ([1f67bf2](https://github.com/laravel/dusk/commit/1f67bf204fab65a212975683b5391c2f55dd3bcf))
## [v5.0.0 (2019-02-12)](https://github.com/laravel/dusk/compare/v4.0.5...v5.0.0)
### Added
- Support PHPUnit 8 ([788e79c](https://github.com/laravel/dusk/commit/788e79c4713a5706eeafaf7270986a71a4ed43be))
- Support Carbon 2 ([85cfc50](https://github.com/laravel/dusk/commit/85cfc50e126a3835428577052cc0660d1c3b639e))
- Support Laravel 5.8 ([5b2e9aa](https://github.com/laravel/dusk/commit/5b2e9aa9e4a124f4c4878f65fb644101de1644e7))
### Changed
- Update minimum Laravel version ([fcb2226](https://github.com/laravel/dusk/commit/fcb2226a524f47b51b9b49316d87bc51738733d7))
- Update Symfony dependencies to latest version ([788e79c](https://github.com/laravel/dusk/commit/788e79c4713a5706eeafaf7270986a71a4ed43be))
- Prefer stable dependencies ([fdb2fd4](https://github.com/laravel/dusk/commit/fdb2fd4b2a2e787b08cf44649c4eef84837324ca))
## [v4.0.0 (2018-08-11)](https://github.com/laravel/dusk/compare/v3.0.10...v4.0.0)
Dusk 4.0.0 disables cookie serialization and is intended for use with Laravel 5.6.30 or later. If you are using a previous version of Laravel, please continue using Dusk 3.0.0.
## v1.0.13 (2017-04-20)
### Added
- Added `illuminate/console` as dependency ([#232](https://github.com/laravel/dusk/pull/232))
- Added security measurement against registering Dusk on production ([#229](https://github.com/laravel/dusk/pull/229))
- Added `PHP_BINARY` constant to the list of PHP's executable binaries ([#240](https://github.com/laravel/dusk/pull/240))
### Changed
- Changed `propagateScaffoldingToBrowser()` to `setUp()` for compatibility with PHPUnit ~6.0 ([#227](https://github.com/laravel/dusk/pull/227))
- Changed `selected()` comparison to always cast the value to string ([#239](https://github.com/laravel/dusk/pull/239))
### Fixed
- No longer throws exception when Tty is not available ([#226](https://github.com/laravel/dusk/pull/226))
- Use `getAttribute('value')` instead of `getText()` for `textarea` elements ([#237](https://github.com/laravel/dusk/pull/237))
- Fixed bug when giving strings with apostrophe to `clickLink()` ([#228](https://github.com/laravel/dusk/pull/228))
## v1.0.12 (2017-04-07)
### Added
- Added automated tests for HTML elements identified by strings with a colon ([#214](https://github.com/laravel/dusk/pull/214))
### Fixed
- Support for colon on HTML id tag ([#214](https://github.com/laravel/dusk/pull/214))
## v1.0.11 (2017-03-20)
### Added
- Added `assertSelectHasOptions()`, `assertSelectMissingOptions()`, `assertSelectHasOption()` and `and assertSelectMissingOption()` ([#195](https://github.com/laravel/dusk/pull/195))
- Added purge console logs before starting tests ([#193](https://github.com/laravel/dusk/pull/193))
- Added `assertPathIsNot()` ([#183](https://github.com/laravel/dusk/pull/183))
- Added support for back button ([#187](https://github.com/laravel/dusk/pull/187))
- Added `waitForLocation()` to allow waiting on `window.location` to be changed ([#176](https://github.com/laravel/dusk/pull/176))
### Changed
- Updated ChromeDriver to v2.28 so that it works with Chrome 57 ([#199](https://github.com/laravel/dusk/pull/199))
- Comparison to `option` inside `select` will no longer be strict ([#178](https://github.com/laravel/dusk/pull/178))
- Type-hint Browser for auto-complete support ([#174](https://github.com/laravel/dusk/pull/174))

View File

@@ -1,10 +1,10 @@
<p align="center"><img src="https://laravel.com/assets/img/components/logo-dusk.svg"></p>
<p align="center"><img src="/art/logo.svg" alt="Logo Laravel Dusk"></p>
<p align="center">
<a href="https://github.com/laravel/dusk/actions"><img src="https://github.com/laravel/dusk/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://poser.pugx.org/laravel/dusk/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://poser.pugx.org/laravel/dusk/v/stable.svg" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://poser.pugx.org/laravel/dusk/license.svg" alt="License"></a>
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://img.shields.io/packagist/dt/laravel/dusk" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://img.shields.io/packagist/v/laravel/dusk" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://img.shields.io/packagist/l/laravel/dusk" alt="License"></a>
</p>
## Introduction

View File

@@ -1,17 +0,0 @@
# Upgrade Guide
## Upgrading To 5.0 From 4.0
### PHPUnit 8
Dusk now provides optional support for PHPUnit 8, which requires PHP >= 7.2 Please read through the entire list of changes in [the PHPUnit 8 release announcement](https://phpunit.de/announcements/phpunit-8.html). Using PHPUnit 8 will require Laravel 5.8, which will be released at the end of February 2019.
You may also continue using PHPUnit 7, which requires a minimum of PHP 7.1.
### Minimum Laravel version
Laravel 5.7 is now the minimum supported version of the framework and you should upgrade to continue using Dusk.
### `setUp` and `tearDown` changes
The `setUp` and `tearDown` methods now require the `void` return type. If you were overwriting these methods you should add it to the method signatures.

1
vendor/laravel/dusk/art/logo.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -10,21 +10,22 @@
}
],
"require": {
"php": ">=7.1.0",
"php": "^7.2|^8.0",
"ext-json": "*",
"ext-zip": "*",
"php-webdriver/webdriver": "^1.8.1",
"nesbot/carbon": "^1.20|^2.0",
"illuminate/console": "~5.7.0|~5.8.0|^6.0|^7.0",
"illuminate/support": "~5.7.0|~5.8.0|^6.0|^7.0",
"symfony/console": "^4.0|^5.0",
"symfony/finder": "^4.0|^5.0",
"symfony/process": "^4.0|^5.0",
"vlucas/phpdotenv": "^2.2|^3.0|^4.0"
"php-webdriver/webdriver": "^1.9.0",
"nesbot/carbon": "^2.0",
"illuminate/console": "^6.0|^7.0|^8.0|^9.0",
"illuminate/support": "^6.0|^7.0|^8.0|^9.0",
"symfony/console": "^4.3|^5.0|^6.0",
"symfony/finder": "^4.3|^5.0|^6.0",
"symfony/process": "^4.3|^5.0|^6.0",
"vlucas/phpdotenv": "^3.0|^4.0|^5.2"
},
"require-dev": {
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.5|^8.0"
"phpunit/phpunit": "^7.5.15|^8.4|^9.0",
"orchestra/testbench": "^4.16|^5.17.1|^6.12.1|^7.0"
},
"suggest": {
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
@@ -41,7 +42,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "5.0-dev"
"dev-master": "6.x-dev"
},
"laravel": {
"providers": [

View File

@@ -39,6 +39,38 @@ class Browser
*/
public static $storeScreenshotsAt;
/**
* The common screen sizes to use for responsive screenshots.
*
* @var array
*/
public static $responsiveScreenSizes = [
'xs' => [
'width' => 360,
'height' => 640,
],
'sm' => [
'width' => 640,
'height' => 360,
],
'md' => [
'width' => 768,
'height' => 1024,
],
'lg' => [
'width' => 1024,
'height' => 768,
],
'xl' => [
'width' => 1280,
'height' => 1024,
],
'2xl' => [
'width' => 1536,
'height' => 864,
],
];
/**
* The directory that will contain any console logs.
*
@@ -116,7 +148,7 @@ class Browser
* Create a browser instance.
*
* @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver
* @param \Laravel\Dusk\ElementResolver $resolver
* @param \Laravel\Dusk\ElementResolver|null $resolver
* @return void
*/
public function __construct($driver, $resolver = null)
@@ -174,6 +206,18 @@ class Browser
return $this->visit(route($route, $parameters));
}
/**
* Browse to the "about:blank" page.
*
* @return $this
*/
public function blank()
{
$this->driver->navigate()->to('about:blank');
return $this;
}
/**
* Set the current page object.
*
@@ -233,6 +277,18 @@ class Browser
return $this;
}
/**
* Navigate to the next page.
*
* @return $this
*/
public function forward()
{
$this->driver->navigate()->forward();
return $this;
}
/**
* Maximize the browser window.
*
@@ -272,7 +328,7 @@ class Browser
$html = $this->driver->findElement(WebDriverBy::tagName('html'));
if (! empty($html) && ($html->getSize()->getWidth() <= 0 || $html->getSize()->getHeight() <= 0)) {
if (! empty($html) && $html->getSize()->getWidth() > 0 && $html->getSize()->getHeight() > 0) {
$this->resize($html->getSize()->getWidth(), $html->getSize()->getHeight());
}
@@ -319,6 +375,21 @@ class Browser
return $this;
}
/**
* Scroll element into view at the given selector.
*
* @param string $selector
* @return $this
*/
public function scrollIntoView($selector)
{
$selector = addslashes($this->resolver->format($selector));
$this->driver->executeScript("document.querySelector(\"$selector\").scrollIntoView();");
return $this;
}
/**
* Scroll screen to element at the given selector.
*
@@ -357,6 +428,26 @@ class Browser
return $this;
}
/**
* Take a series of screenshots at different browser sizes to emulate different devices.
*
* @param string $name
* @return $this
*/
public function responsiveScreenshots($name)
{
if (substr($name, -1) !== '/') {
$name .= '-';
}
foreach (static::$responsiveScreenSizes as $device => $size) {
$this->resize($size['width'], $size['height'])
->screenshot("$name$device");
}
return $this;
}
/**
* Store the console output with the given name.
*
@@ -418,7 +509,7 @@ class Browser
/**
* Execute a Closure with a scoped browser instance.
*
* @param string $selector
* @param string|\Laravel\Dusk\Component $selector
* @param \Closure $callback
* @return $this
*/
@@ -430,7 +521,7 @@ class Browser
/**
* Execute a Closure with a scoped browser instance.
*
* @param string $selector
* @param string|\Laravel\Dusk\Component $selector
* @param \Closure $callback
* @return $this
*/
@@ -453,6 +544,47 @@ class Browser
return $this;
}
/**
* Execute a Closure outside of the current browser scope.
*
* @param string|\Laravel\Dusk\Component $selector
* @param \Closure $callback
* @return $this
*/
public function elsewhere($selector, Closure $callback)
{
$browser = new static(
$this->driver, new ElementResolver($this->driver, 'body '.$selector)
);
if ($this->page) {
$browser->onWithoutAssert($this->page);
}
if ($selector instanceof Component) {
$browser->onComponent($selector, $this->resolver);
}
call_user_func($callback, $browser);
return $this;
}
/**
* Execute a Closure outside of the current browser scope when the selector is available.
*
* @param string $selector
* @param \Closure $callback
* @param int|null $seconds
* @return $this
*/
public function elsewhereWhenAvailable($selector, Closure $callback, $seconds = null)
{
return $this->elsewhere('', function ($browser) use ($selector, $callback, $seconds) {
$browser->whenAvailable($selector, $callback, $seconds);
});
}
/**
* Set the current component state.
*

View File

@@ -18,7 +18,7 @@ class ChromeProcess
/**
* Create a new ChromeProcess instance.
*
* @param string $driver
* @param string|null $driver
* @return void
*
* @throws \RuntimeException
@@ -44,13 +44,15 @@ class ChromeProcess
return $this->process($arguments);
}
if ($this->onWindows()) {
$this->driver = realpath(__DIR__.'/../../bin/chromedriver-win.exe');
} elseif ($this->onMac()) {
$this->driver = realpath(__DIR__.'/../../bin/chromedriver-mac');
} else {
$this->driver = realpath(__DIR__.'/../../bin/chromedriver-linux');
}
$filenames = [
'linux' => 'chromedriver-linux',
'mac' => 'chromedriver-mac',
'mac-intel' => 'chromedriver-mac-intel',
'mac-arm' => 'chromedriver-mac-arm',
'win' => 'chromedriver-win.exe',
];
$this->driver = realpath(__DIR__.'/../../bin').DIRECTORY_SEPARATOR.$filenames[$this->operatingSystemId()];
return $this->process($arguments);
}
@@ -64,7 +66,7 @@ class ChromeProcess
protected function process(array $arguments = [])
{
return new Process(
array_merge([realpath($this->driver)], $arguments), null, $this->chromeEnvironment()
array_merge([$this->driver], $arguments), null, $this->chromeEnvironment()
);
}
@@ -101,4 +103,14 @@ class ChromeProcess
{
return OperatingSystem::onMac();
}
/**
* Determine OS ID.
*
* @return string
*/
protected function operatingSystemId()
{
return OperatingSystem::id();
}
}

View File

@@ -21,25 +21,25 @@ trait InteractsWithAuthentication
* Log into the application using a given user ID or email.
*
* @param object|string $userId
* @param string $guard
* @param string|null $guard
* @return $this
*/
public function loginAs($userId, $guard = null)
{
$userId = method_exists($userId, 'getKey') ? $userId->getKey() : $userId;
$userId = is_object($userId) && method_exists($userId, 'getKey') ? $userId->getKey() : $userId;
return $this->visit(rtrim('/_dusk/login/'.$userId.'/'.$guard, '/'));
return $this->visit(rtrim(route('dusk.login', ['userId' => $userId, 'guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication())));
}
/**
* Log out of the application.
*
* @param string $guard
* @param string|null $guard
* @return $this
*/
public function logout($guard = null)
{
return $this->visit(rtrim('/_dusk/logout/'.$guard, '/'));
return $this->visit(rtrim(route('dusk.logout', ['guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication()), '/'));
}
/**
@@ -50,7 +50,7 @@ trait InteractsWithAuthentication
*/
protected function currentUserInfo($guard = null)
{
$response = $this->visit("/_dusk/user/{$guard}");
$response = $this->visit(route('dusk.user', ['guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication()));
return json_decode(strip_tags($response->driver->getPageSource()), true);
}
@@ -63,9 +63,11 @@ trait InteractsWithAuthentication
*/
public function assertAuthenticated($guard = null)
{
$currentUrl = $this->driver->getCurrentURL();
PHPUnit::assertNotEmpty($this->currentUserInfo($guard), 'The user is not authenticated.');
return $this;
return $this->visit($currentUrl);
}
/**
@@ -76,11 +78,13 @@ trait InteractsWithAuthentication
*/
public function assertGuest($guard = null)
{
$currentUrl = $this->driver->getCurrentURL();
PHPUnit::assertEmpty(
$this->currentUserInfo($guard), 'The user is unexpectedly authenticated.'
);
return $this;
return $this->visit($currentUrl);
}
/**
@@ -92,6 +96,8 @@ trait InteractsWithAuthentication
*/
public function assertAuthenticatedAs($user, $guard = null)
{
$currentUrl = $this->driver->getCurrentURL();
$expected = [
'id' => $user->getAuthIdentifier(),
'className' => get_class($user),
@@ -102,6 +108,16 @@ trait InteractsWithAuthentication
'The currently authenticated user is not who was expected.'
);
return $this;
return $this->visit($currentUrl);
}
/**
* Determine if route() should use an absolute path.
*
* @return bool
*/
private function shouldUseAbsoluteRouteForAuthentication()
{
return config('dusk.domain') !== null;
}
}

View File

@@ -3,6 +3,9 @@
namespace Laravel\Dusk\Concerns;
use DateTimeInterface;
use Facebook\WebDriver\Exception\NoSuchCookieException;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Support\Facades\Crypt;
trait InteractsWithCookies
{
@@ -13,7 +16,7 @@ trait InteractsWithCookies
* @param string|null $value
* @param int|DateTimeInterface|null $expiry
* @param array $options
* @return string
* @return $this|string
*/
public function cookie($name, $value = null, $expiry = null, array $options = [])
{
@@ -21,19 +24,29 @@ trait InteractsWithCookies
return $this->addCookie($name, $value, $expiry, $options);
}
if ($cookie = $this->driver->manage()->getCookieNamed($name)) {
return decrypt(rawurldecode($cookie['value']), $unserialize = false);
try {
$cookie = $this->driver->manage()->getCookieNamed($name);
} catch (NoSuchCookieException $e) {
$cookie = null;
}
if ($cookie) {
$decryptedValue = decrypt(rawurldecode($cookie['value']), $unserialize = false);
$hasValuePrefix = strpos($decryptedValue, CookieValuePrefix::create($name, Crypt::getKey())) === 0;
return $hasValuePrefix ? CookieValuePrefix::remove($decryptedValue) : $decryptedValue;
}
}
/**
* Get or set a plain cookie's value.
* Get or set an unencrypted cookie's value.
*
* @param string $name
* @param string|null $value
* @param int|DateTimeInterface|null $expiry
* @param array $options
* @return string
* @return $this|string
*/
public function plainCookie($name, $value = null, $expiry = null, array $options = [])
{
@@ -41,7 +54,13 @@ trait InteractsWithCookies
return $this->addCookie($name, $value, $expiry, $options, false);
}
if ($cookie = $this->driver->manage()->getCookieNamed($name)) {
try {
$cookie = $this->driver->manage()->getCookieNamed($name);
} catch (NoSuchCookieException $e) {
$cookie = null;
}
if ($cookie) {
return rawurldecode($cookie['value']);
}
}
@@ -59,7 +78,9 @@ trait InteractsWithCookies
public function addCookie($name, $value, $expiry = null, array $options = [], $encrypt = true)
{
if ($encrypt) {
$value = encrypt($value, $serialize = false);
$prefix = CookieValuePrefix::create($name, Crypt::getKey());
$value = encrypt($prefix.$value, $serialize = false);
}
if ($expiry instanceof DateTimeInterface) {

View File

@@ -6,6 +6,8 @@ use Facebook\WebDriver\Interactions\WebDriverActions;
use Facebook\WebDriver\Remote\LocalFileDetector;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverKeys;
use Facebook\WebDriver\WebDriverSelect;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
trait InteractsWithElements
@@ -43,9 +45,11 @@ trait InteractsWithElements
{
$this->ensurejQueryIsAvailable();
$selector = addslashes(trim($this->resolver->format("{$element}:contains({$link}):visible")));
$selector = addslashes(trim($this->resolver->format("{$element}")));
$this->driver->executeScript("jQuery.find(\"{$selector}\")[0].click();");
$link = str_replace("'", "\\\\'", $link);
$this->driver->executeScript("jQuery.find(`{$selector}:contains('{$link}'):visible`)[0].click();");
return $this;
}
@@ -99,7 +103,7 @@ trait InteractsWithElements
* Send the given keys to the element matching the given selector.
*
* @param string $selector
* @param dynamic $keys
* @param mixed $keys
* @return $this
*/
public function keys($selector, ...$keys)
@@ -183,7 +187,7 @@ trait InteractsWithElements
*/
public function appendSlowly($field, $value, $pause = 100)
{
foreach (str_split($value) as $char) {
foreach (preg_split('//u', $value, -1, PREG_SPLIT_NO_EMPTY) as $char) {
$this->append($field, $char)->pause($pause);
}
@@ -207,7 +211,7 @@ trait InteractsWithElements
* Select the given value or random value of a drop-down field.
*
* @param string $field
* @param string $value
* @param string|array|null $value
* @return $this
*/
public function select($field, $value = null)
@@ -216,18 +220,34 @@ trait InteractsWithElements
$options = $element->findElements(WebDriverBy::cssSelector('option:not([disabled])'));
if (is_null($value)) {
$select = $element->getTagName() === 'select' ? new WebDriverSelect($element) : null;
$isMultiple = false;
if (! is_null($select)) {
if ($isMultiple = $select->isMultiple()) {
$select->deselectAll();
}
}
if (func_num_args() === 1) {
$options[array_rand($options)]->click();
} else {
if (is_bool($value)) {
$value = $value ? '1' : '0';
}
$value = collect(Arr::wrap($value))->transform(function ($value) {
if (is_bool($value)) {
return $value ? '1' : '0';
}
return (string) $value;
})->all();
foreach ($options as $option) {
if ((string) $option->getAttribute('value') === (string) $value) {
if (in_array((string) $option->getAttribute('value'), $value)) {
$option->click();
break;
if (! $isMultiple) {
break;
}
}
}
}
@@ -253,7 +273,7 @@ trait InteractsWithElements
* Check the given checkbox.
*
* @param string $field
* @param string $value
* @param string|null $value
* @return $this
*/
public function check($field, $value = null)
@@ -271,7 +291,7 @@ trait InteractsWithElements
* Uncheck the given checkbox.
*
* @param string $field
* @param string $value
* @param string|null $value
* @return $this
*/
public function uncheck($field, $value = null)

View File

@@ -7,7 +7,7 @@ trait InteractsWithJavascript
/**
* Execute JavaScript within the browser.
*
* @param string|array $scripts
* @param string|array $scripts
* @return array
*/
public function script($scripts)

View File

@@ -2,6 +2,8 @@
namespace Laravel\Dusk\Concerns;
use Facebook\WebDriver\Exception\ElementClickInterceptedException;
use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Interactions\WebDriverActions;
use Facebook\WebDriver\WebDriverBy;
@@ -48,17 +50,41 @@ trait InteractsWithMouse
{
if (is_null($selector)) {
(new WebDriverActions($this->driver))->click()->perform();
} else {
$this->resolver->findOrFail($selector)->click();
return $this;
}
foreach ($this->resolver->all($selector) as $element) {
try {
$element->click();
return $this;
} catch (ElementClickInterceptedException $e) {
//
}
}
throw $e ?? new NoSuchElementException("Unable to locate element with selector [{$selector}].");
}
/**
* Click the topmost element at the given pair of coordinates.
*
* @param int $x
* @param int $y
* @return $this
*/
public function clickAtPoint($x, $y)
{
$this->driver->executeScript("document.elementFromPoint({$x}, {$y}).click()");
return $this;
}
/**
* Click the element at the given XPath expression.
*
* @param string $selector
* @param string $expression
* @return $this
*/
public function clickAtXPath($expression)

View File

@@ -10,7 +10,14 @@ use PHPUnit\Framework\Assert as PHPUnit;
trait MakesAssertions
{
/**
* Assert that the page title is the given value.
* Indicates the browser has made an assertion about the source code of the page.
*
* @var bool
*/
public $madeSourceAssertion = false;
/**
* Assert that the page title matches the given text.
*
* @param string $title
* @return $this
@@ -26,7 +33,7 @@ trait MakesAssertions
}
/**
* Assert that the page title contains the given value.
* Assert that the page title contains the given text.
*
* @param string $title
* @return $this
@@ -35,16 +42,16 @@ trait MakesAssertions
{
PHPUnit::assertTrue(
Str::contains($this->driver->getTitle(), $title),
"Did not see expected value [{$title}] within title [{$this->driver->getTitle()}]."
"Did not see expected text [{$title}] within title [{$this->driver->getTitle()}]."
);
return $this;
}
/**
* Assert that the given cookie is present.
* Assert that the given encrypted cookie is present.
*
* @param string $name
* @param string $name
* @param bool $decrypt
* @return $this
*/
@@ -61,7 +68,7 @@ trait MakesAssertions
}
/**
* Assert that the given plain cookie is present.
* Assert that the given unencrypted cookie is present.
*
* @param string $name
* @return $this
@@ -72,9 +79,9 @@ trait MakesAssertions
}
/**
* Assert that the given cookie is not present.
* Assert that the given encrypted cookie is not present.
*
* @param string $name
* @param string $name
* @param bool $decrypt
* @return $this
*/
@@ -91,7 +98,7 @@ trait MakesAssertions
}
/**
* Assert that the given plain cookie is not present.
* Assert that the given unencrypted cookie is not present.
*
* @param string $name
* @return $this
@@ -114,7 +121,8 @@ trait MakesAssertions
$actual = $decrypt ? $this->cookie($name) : $this->plainCookie($name);
PHPUnit::assertEquals(
$value, $actual,
$value,
$actual,
"Cookie [{$name}] had value [{$actual}], but expected [{$value}]."
);
@@ -122,7 +130,7 @@ trait MakesAssertions
}
/**
* Assert that a cookie has a given value.
* Assert that an unencrypted cookie has a given value.
*
* @param string $name
* @param string $value
@@ -134,7 +142,7 @@ trait MakesAssertions
}
/**
* Assert that the given text appears on the page.
* Assert that the given text is present on the page.
*
* @param string $text
* @return $this
@@ -145,7 +153,7 @@ trait MakesAssertions
}
/**
* Assert that the given text does not appear on the page.
* Assert that the given text is not present on the page.
*
* @param string $text
* @return $this
@@ -156,7 +164,7 @@ trait MakesAssertions
}
/**
* Assert that the given text appears within the given selector.
* Assert that the given text is present within the selector.
*
* @param string $selector
* @param string $text
@@ -177,7 +185,7 @@ trait MakesAssertions
}
/**
* Assert that the given text does not appear within the given selector.
* Assert that the given text is not present within the selector.
*
* @param string $selector
* @param string $text
@@ -197,6 +205,66 @@ trait MakesAssertions
return $this;
}
/**
* Assert that any text is present within the selector.
*
* @param string $selector
* @return $this
*/
public function assertSeeAnythingIn($selector)
{
$fullSelector = $this->resolver->format($selector);
$element = $this->resolver->findOrFail($selector);
PHPUnit::assertTrue(
$element->getText() !== '',
"Saw unexpected text [''] within element [{$fullSelector}]."
);
return $this;
}
/**
* Assert that no text is present within the selector.
*
* @param string $selector
* @return $this
*/
public function assertSeeNothingIn($selector)
{
$fullSelector = $this->resolver->format($selector);
$element = $this->resolver->findOrFail($selector);
PHPUnit::assertTrue(
$element->getText() === '',
"Did not see expected text [''] within element [{$fullSelector}]."
);
return $this;
}
/**
* Assert that the given JavaScript expression evaluates to the given value.
*
* @param string $expression
* @param mixed $expected
* @return $this
*/
public function assertScript($expression, $expected = true)
{
$expression = Str::start($expression, 'return ');
PHPUnit::assertEquals(
$expected,
$this->driver->executeScript($expression),
"JavaScript expression [{$expression}] mismatched."
);
return $this;
}
/**
* Assert that the given source code is present on the page.
*
@@ -205,9 +273,11 @@ trait MakesAssertions
*/
public function assertSourceHas($code)
{
$this->madeSourceAssertion = true;
PHPUnit::assertTrue(
Str::contains($this->driver->getPageSource(), $code),
"Did not find expected source code [{$code}]"
"Did not find expected source code [{$code}]."
);
return $this;
@@ -221,16 +291,18 @@ trait MakesAssertions
*/
public function assertSourceMissing($code)
{
$this->madeSourceAssertion = true;
PHPUnit::assertFalse(
Str::contains($this->driver->getPageSource(), $code),
"Found unexpected source code [{$code}]"
"Found unexpected source code [{$code}]."
);
return $this;
}
/**
* Assert that the given link is visible.
* Assert that the given link is present on the page.
*
* @param string $link
* @return $this
@@ -252,7 +324,7 @@ trait MakesAssertions
}
/**
* Assert that the given link is not visible.
* Assert that the given link is not present on the page.
*
* @param string $link
* @return $this
@@ -283,10 +355,12 @@ trait MakesAssertions
{
$this->ensurejQueryIsAvailable();
$selector = addslashes(trim($this->resolver->format("a:contains('{$link}')")));
$selector = addslashes(trim($this->resolver->format('a')));
$link = str_replace("'", "\\\\'", $link);
$script = <<<JS
var link = jQuery.find("{$selector}");
var link = jQuery.find(`{$selector}:contains('{$link}')`);
return link.length > 0 && jQuery(link).is(':visible');
JS;
@@ -294,7 +368,7 @@ JS;
}
/**
* Assert that the given input or text area contains the given value.
* Assert that the given input field has the given value.
*
* @param string $field
* @param string $value
@@ -303,7 +377,8 @@ JS;
public function assertInputValue($field, $value)
{
PHPUnit::assertEquals(
$value, $this->inputValue($field),
$value,
$this->inputValue($field),
"Expected value [{$value}] for the [{$field}] input does not equal the actual value [{$this->inputValue($field)}]."
);
@@ -311,7 +386,7 @@ JS;
}
/**
* Assert that the given input or text area does not contain the given value.
* Assert that the given input field does not have the given value.
*
* @param string $field
* @param string $value
@@ -320,7 +395,8 @@ JS;
public function assertInputValueIsNot($field, $value)
{
PHPUnit::assertNotEquals(
$value, $this->inputValue($field),
$value,
$this->inputValue($field),
"Value [{$value}] for the [{$field}] input should not equal the actual value."
);
@@ -343,10 +419,40 @@ JS;
}
/**
* Assert that the given checkbox field is checked.
* Assert that the given input field is present.
*
* @param string $field
* @param string $value
* @return $this
*/
public function assertInputPresent($field)
{
$this->assertPresent(
"input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']"
);
return $this;
}
/**
* Assert that the given input field is not visible.
*
* @param string $field
* @return $this
*/
public function assertInputMissing($field)
{
$this->assertMissing(
"input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']"
);
return $this;
}
/**
* Assert that the given checkbox is checked.
*
* @param string $field
* @param string|null $value
* @return $this
*/
public function assertChecked($field, $value = null)
@@ -362,10 +468,10 @@ JS;
}
/**
* Assert that the given checkbox field is not checked.
* Assert that the given checkbox is not checked.
*
* @param string $field
* @param string $value
* @param string|null $value
* @return $this
*/
public function assertNotChecked($field, $value = null)
@@ -380,6 +486,26 @@ JS;
return $this;
}
/**
* Assert that the given checkbox is in an indeterminate state.
*
* @param string $field
* @param string|null $value
* @return $this
*/
public function assertIndeterminate($field, $value = null)
{
$this->assertNotChecked($field, $value);
PHPUnit::assertSame(
'true',
$this->resolver->findOrFail($field)->getAttribute('indeterminate'),
"Checkbox [{$field}] was not in indeterminate state."
);
return $this;
}
/**
* Assert that the given radio field is selected.
*
@@ -403,7 +529,7 @@ JS;
* Assert that the given radio field is not selected.
*
* @param string $field
* @param string $value
* @param string|null $value
* @return $this
*/
public function assertRadioNotSelected($field, $value = null)
@@ -419,7 +545,7 @@ JS;
}
/**
* Assert that the given select field has the given value selected.
* Assert that the given dropdown has the given value selected.
*
* @param string $field
* @param string $value
@@ -436,7 +562,7 @@ JS;
}
/**
* Assert that the given select field does not have the given value selected.
* Assert that the given dropdown does not have the given value selected.
*
* @param string $field
* @param string $value
@@ -468,7 +594,8 @@ JS;
})->all();
PHPUnit::assertCount(
count($values), $options,
count($values),
$options,
'Expected options ['.implode(',', $values)."] for selection field [{$field}] to be available."
);
@@ -485,7 +612,8 @@ JS;
public function assertSelectMissingOptions($field, array $values)
{
PHPUnit::assertCount(
0, $this->resolver->resolveSelectOptions($field, $values),
0,
$this->resolver->resolveSelectOptions($field, $values),
'Unexpected options ['.implode(',', $values)."] for selection field [{$field}]."
);
@@ -505,7 +633,7 @@ JS;
}
/**
* Assert that the given value is not available to be selected on the given field.
* Assert that the given value is not available to be selected.
*
* @param string $field
* @param string $value
@@ -533,7 +661,7 @@ JS;
}
/**
* Assert that the element at the given selector has the given value.
* Assert that the element matching the given selector has the given value.
*
* @param string $selector
* @param string $value
@@ -541,15 +669,75 @@ JS;
*/
public function assertValue($selector, $value)
{
$actual = $this->resolver->findOrFail($selector)->getAttribute('value');
$fullSelector = $this->resolver->format($selector);
PHPUnit::assertEquals($value, $actual);
$this->ensureElementSupportsValueAttribute(
$element = $this->resolver->findOrFail($selector),
$fullSelector
);
$actual = $element->getAttribute('value');
PHPUnit::assertEquals(
$value,
$actual,
"Did not see expected value [{$value}] within element [{$fullSelector}]."
);
return $this;
}
/**
* Assert that the element at the given selector has the given attribute value.
* Assert that the element matching the given selector does not have the given value.
*
* @param string $selector
* @param string $value
* @return $this
*/
public function assertValueIsNot($selector, $value)
{
$fullSelector = $this->resolver->format($selector);
$this->ensureElementSupportsValueAttribute(
$element = $this->resolver->findOrFail($selector),
$fullSelector
);
$actual = $element->getAttribute('value');
PHPUnit::assertNotEquals(
$value,
$actual,
"Saw unexpected value [{$value}] within element [{$fullSelector}]."
);
return $this;
}
/**
* Ensure the given element supports the 'value' attribute.
*
* @param mixed $element
* @param string $fullSelector
* @return void
*/
public function ensureElementSupportsValueAttribute($element, $fullSelector)
{
PHPUnit::assertTrue(in_array($element->getTagName(), [
'textarea',
'select',
'button',
'input',
'li',
'meter',
'option',
'param',
'progress',
]), "This assertion cannot be used with the element [{$fullSelector}].");
}
/**
* Assert that the element matching the given selector has the given value in the provided attribute.
*
* @param string $selector
* @param string $attribute
@@ -568,7 +756,8 @@ JS;
);
PHPUnit::assertEquals(
$value, $actual,
$value,
$actual,
"Expected '$attribute' attribute [{$value}] does not equal actual value [$actual]."
);
@@ -576,20 +765,35 @@ JS;
}
/**
* Assert that the element at the given selector has the given data attribute value.
* Assert that the element matching the given selector contains the given value in the provided attribute.
*
* @param string $selector
* @param string $attribute
* @param string $value
* @return $this
*/
public function assertDataAttribute($selector, $attribute, $value)
public function assertAttributeContains($selector, $attribute, $value)
{
return $this->assertAttribute($selector, 'data-'.$attribute, $value);
$fullSelector = $this->resolver->format($selector);
$actual = $this->resolver->findOrFail($selector)->getAttribute($attribute);
PHPUnit::assertNotNull(
$actual,
"Did not see expected attribute [{$attribute}] within element [{$fullSelector}]."
);
PHPUnit::assertStringContainsString(
$value,
$actual,
"Attribute '$attribute' does not contain [{$value}]. Full attribute value was [$actual]."
);
return $this;
}
/**
* Assert that the element at the given selector has the given aria attribute value.
* Assert that the element matching the given selector has the given value in the provided aria attribute.
*
* @param string $selector
* @param string $attribute
@@ -602,7 +806,20 @@ JS;
}
/**
* Assert that the element with the given selector is visible.
* Assert that the element matching the given selector has the given value in the provided data attribute.
*
* @param string $selector
* @param string $attribute
* @param string $value
* @return $this
*/
public function assertDataAttribute($selector, $attribute, $value)
{
return $this->assertAttribute($selector, 'data-'.$attribute, $value);
}
/**
* Assert that the element matching the given selector is visible.
*
* @param string $selector
* @return $this
@@ -620,7 +837,7 @@ JS;
}
/**
* Assert that the element with the given selector is present in the DOM.
* Assert that the element matching the given selector is present.
*
* @param string $selector
* @return $this
@@ -638,7 +855,25 @@ JS;
}
/**
* Assert that the element with the given selector is not on the page.
* Assert that the element matching the given selector is not present in the source.
*
* @param string $selector
* @return $this
*/
public function assertNotPresent($selector)
{
$fullSelector = $this->resolver->format($selector);
PHPUnit::assertTrue(
is_null($this->resolver->find($selector)),
"Element [{$fullSelector}] is present."
);
return $this;
}
/**
* Assert that the element matching the given selector is not visible.
*
* @param string $selector
* @return $this
@@ -653,13 +888,16 @@ JS;
$missing = true;
}
PHPUnit::assertTrue($missing, "Saw unexpected element [{$fullSelector}].");
PHPUnit::assertTrue(
$missing,
"Saw unexpected element [{$fullSelector}]."
);
return $this;
}
/**
* Assert that a JavaScript dialog with given message has been opened.
* Assert that a JavaScript dialog with the given message has been opened.
*
* @param string $message
* @return $this
@@ -669,7 +907,8 @@ JS;
$actualMessage = $this->driver->switchTo()->alert()->getText();
PHPUnit::assertEquals(
$message, $actualMessage,
$message,
$actualMessage,
"Expected dialog message [{$message}] does not equal actual message [{$actualMessage}]."
);
@@ -788,36 +1027,46 @@ JS;
* Assert that the Vue component's attribute at the given key has the given value.
*
* @param string $key
* @param string $value
* @param mixed $value
* @param string|null $componentSelector
* @return $this
*/
public function assertVue($key, $value, $componentSelector = null)
{
PHPUnit::assertEquals($value, $this->vueAttribute($componentSelector, $key));
$formattedValue = json_encode($value);
PHPUnit::assertEquals(
$value,
$this->vueAttribute($componentSelector, $key),
"Did not see expected value [{$formattedValue}] at the key [{$key}]."
);
return $this;
}
/**
* Assert that the Vue component's attribute at the given key
* does not have the given value.
* Assert that a given Vue component data property does not match the given value.
*
* @param string $key
* @param string $value
* @param mixed $value
* @param string|null $componentSelector
* @return $this
*/
public function assertVueIsNot($key, $value, $componentSelector = null)
{
PHPUnit::assertNotEquals($value, $this->vueAttribute($componentSelector, $key));
$formattedValue = json_encode($value);
PHPUnit::assertNotEquals(
$value,
$this->vueAttribute($componentSelector, $key),
"Saw unexpected value [{$formattedValue}] at the key [{$key}]."
);
return $this;
}
/**
* Assert that the Vue component's attribute at the given key
* is an array that contains the given value.
* Assert that a given Vue component data propertys is an array and contains the given value.
*
* @param string $key
* @param string $value
@@ -828,15 +1077,18 @@ JS;
{
$attribute = $this->vueAttribute($componentSelector, $key);
PHPUnit::assertIsArray($attribute, "The attribute for key [$key] is not an array.");
PHPUnit::assertIsArray(
$attribute,
"The attribute for key [{$key}] is not an array."
);
PHPUnit::assertContains($value, $attribute);
return $this;
}
/**
* Assert that the Vue component's attribute at the given key
* is an array that does not contain the given value.
* Assert that a given Vue component data property is an array and does not contain the given value.
*
* @param string $key
* @param string $value
@@ -847,7 +1099,11 @@ JS;
{
$attribute = $this->vueAttribute($componentSelector, $key);
PHPUnit::assertIsArray($attribute, "The attribute for key [$key] is not an array.");
PHPUnit::assertIsArray(
$attribute,
"The attribute for key [{$key}] is not an array."
);
PHPUnit::assertNotContains($value, $attribute);
return $this;
@@ -865,7 +1121,15 @@ JS;
$fullSelector = $this->resolver->format($componentSelector);
return $this->driver->executeScript(
"return document.querySelector('".$fullSelector."').__vue__.".$key
"var el = document.querySelector('".$fullSelector."');".
"if (typeof el.__vue__ !== 'undefined')".
' return el.__vue__.'.$key.';'.
'try {'.
' var attr = el.__vueParentComponent.ctx.'.$key.';'.
" if (typeof attr !== 'undefined')".
' return attr;'.
'} catch (e) {}'.
'return el.__vueParentComponent.setupState.'.$key.';'
);
}
}

View File

@@ -4,11 +4,12 @@ namespace Laravel\Dusk\Concerns;
use Illuminate\Support\Arr;
use PHPUnit\Framework\Assert as PHPUnit;
use PHPUnit\Framework\Constraint\RegularExpression;
trait MakesUrlAssertions
{
/**
* Assert that the current URL matches the given URL.
* Assert that the current URL (without the query string) matches the given string.
*
* @param string $url
* @return $this
@@ -27,8 +28,8 @@ trait MakesUrlAssertions
Arr::get($segments, 'path', '')
);
PHPUnit::assertRegExp(
'/^'.$pattern.'$/u', $currentUrl,
PHPUnit::assertThat(
$currentUrl, new RegularExpression('/^'.$pattern.'$/u'),
"Actual URL [{$this->driver->getCurrentURL()}] does not equal expected URL [{$url}]."
);
@@ -36,7 +37,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current scheme matches the given scheme.
* Assert that the current URL scheme matches the given scheme.
*
* @param string $scheme
* @return $this
@@ -47,8 +48,8 @@ trait MakesUrlAssertions
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_SCHEME) ?? '';
PHPUnit::assertRegExp(
'/^'.$pattern.'$/u', $actual,
PHPUnit::assertThat(
$actual, new RegularExpression('/^'.$pattern.'$/u'),
"Actual scheme [{$actual}] does not equal expected scheme [{$pattern}]."
);
@@ -56,7 +57,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current scheme does not match the given scheme.
* Assert that the current URL scheme does not match the given scheme.
*
* @param string $scheme
* @return $this
@@ -74,7 +75,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current host matches the given host.
* Assert that the current URL host matches the given host.
*
* @param string $host
* @return $this
@@ -85,8 +86,8 @@ trait MakesUrlAssertions
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_HOST) ?? '';
PHPUnit::assertRegExp(
'/^'.$pattern.'$/u', $actual,
PHPUnit::assertThat(
$actual, new RegularExpression('/^'.$pattern.'$/u'),
"Actual host [{$actual}] does not equal expected host [{$pattern}]."
);
@@ -94,7 +95,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current host does not match the given host.
* Assert that the current URL host does not match the given host.
*
* @param string $host
* @return $this
@@ -112,7 +113,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current port matches the given port.
* Assert that the current URL port matches the given port.
*
* @param string $port
* @return $this
@@ -121,10 +122,10 @@ trait MakesUrlAssertions
{
$pattern = str_replace('\*', '.*', preg_quote($port, '/'));
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_PORT) ?? '';
$actual = (string) parse_url($this->driver->getCurrentURL(), PHP_URL_PORT) ?? '';
PHPUnit::assertRegExp(
'/^'.$pattern.'$/u', $actual,
PHPUnit::assertThat(
$actual, new RegularExpression('/^'.$pattern.'$/u'),
"Actual port [{$actual}] does not equal expected port [{$pattern}]."
);
@@ -132,7 +133,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current host does not match the given host.
* Assert that the current URL port does not match the given port.
*
* @param string $port
* @return $this
@@ -150,27 +151,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current URL path matches the given pattern.
*
* @param string $path
* @return $this
*/
public function assertPathIs($path)
{
$pattern = str_replace('\*', '.*', preg_quote($path, '/'));
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
PHPUnit::assertRegExp(
'/^'.$pattern.'$/u', $actualPath,
"Actual path [{$actualPath}] does not equal expected path [{$path}]."
);
return $this;
}
/**
* Assert that the current URL path begins with given path.
* Assert that the current URL path begins with the given path.
*
* @param string $path
* @return $this
@@ -188,7 +169,27 @@ trait MakesUrlAssertions
}
/**
* Assert that the current URL path does not match the given path.
* Assert that the current path matches the given path.
*
* @param string $path
* @return $this
*/
public function assertPathIs($path)
{
$pattern = str_replace('\*', '.*', preg_quote($path, '/'));
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
PHPUnit::assertThat(
$actualPath, new RegularExpression('/^'.$pattern.'$/u'),
"Actual path [{$actualPath}] does not equal expected path [{$path}]."
);
return $this;
}
/**
* Assert that the current path does not match the given path.
*
* @param string $path
* @return $this
@@ -206,63 +207,7 @@ trait MakesUrlAssertions
}
/**
* Assert that the current URL fragment matches the given pattern.
*
* @param string $fragment
* @return $this
*/
public function assertFragmentIs($fragment)
{
$pattern = preg_quote($fragment, '/');
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
PHPUnit::assertRegExp(
'/^'.str_replace('\*', '.*', $pattern).'$/u', $actualFragment,
"Actual fragment [{$actualFragment}] does not equal expected fragment [{$fragment}]."
);
return $this;
}
/**
* Assert that the current URL fragment begins with given fragment.
*
* @param string $fragment
* @return $this
*/
public function assertFragmentBeginsWith($fragment)
{
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
PHPUnit::assertStringStartsWith(
$fragment, $actualFragment,
"Actual fragment [$actualFragment] does not begin with expected fragment [$fragment]."
);
return $this;
}
/**
* Assert that the current URL fragment does not match the given fragment.
*
* @param string $fragment
* @return $this
*/
public function assertFragmentIsNot($fragment)
{
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
PHPUnit::assertNotEquals(
$fragment, $actualFragment,
"Fragment [{$fragment}] should not equal the actual value."
);
return $this;
}
/**
* Assert that the current URL path matches the given route.
* Assert that the current URL matches the given named route's URL.
*
* @param string $route
* @param array $parameters
@@ -274,10 +219,10 @@ trait MakesUrlAssertions
}
/**
* Assert that a query string parameter is present and has a given value.
* Assert that the given query string parameter is present and has a given value.
*
* @param string $name
* @param string $value
* @param string|null $value
* @return $this
*/
public function assertQueryStringHas($name, $value = null)
@@ -326,6 +271,62 @@ trait MakesUrlAssertions
return $this;
}
/**
* Assert that the URL's current hash fragment matches the given fragment.
*
* @param string $fragment
* @return $this
*/
public function assertFragmentIs($fragment)
{
$pattern = preg_quote($fragment, '/');
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
PHPUnit::assertThat(
$actualFragment, new RegularExpression('/^'.str_replace('\*', '.*', $pattern).'$/u'),
"Actual fragment [{$actualFragment}] does not equal expected fragment [{$fragment}]."
);
return $this;
}
/**
* Assert that the URL's current hash fragment begins with the given fragment.
*
* @param string $fragment
* @return $this
*/
public function assertFragmentBeginsWith($fragment)
{
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
PHPUnit::assertStringStartsWith(
$fragment, $actualFragment,
"Actual fragment [$actualFragment] does not begin with expected fragment [$fragment]."
);
return $this;
}
/**
* Assert that the URL's current hash fragment does not match the given fragment.
*
* @param string $fragment
* @return $this
*/
public function assertFragmentIsNot($fragment)
{
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
PHPUnit::assertNotEquals(
$fragment, $actualFragment,
"Fragment [{$fragment}] should not equal the actual value."
);
return $this;
}
/**
* Assert that the given query string parameter is present.
*

View File

@@ -29,6 +29,7 @@ trait ProvidesBrowser
* Tear down the Dusk test case class.
*
* @afterClass
*
* @return void
*/
public static function tearDownDuskClass()
@@ -68,10 +69,12 @@ trait ProvidesBrowser
$callback(...$browsers->all());
} catch (Exception $e) {
$this->captureFailuresFor($browsers);
$this->storeSourceLogsFor($browsers);
throw $e;
} catch (Throwable $e) {
$this->captureFailuresFor($browsers);
$this->storeSourceLogsFor($browsers);
throw $e;
} finally {
@@ -162,6 +165,22 @@ trait ProvidesBrowser
});
}
/**
* Store the source code for the given browsers (if necessary).
*
* @param \Illuminate\Support\Collection $browsers
* @return void
*/
protected function storeSourceLogsFor($browsers)
{
$browsers->each(function ($browser, $key) {
if (property_exists($browser, 'madeSourceAssertion') &&
$browser->madeSourceAssertion) {
$browser->storeSource($this->getCallerName().'-'.$key);
}
});
}
/**
* Close all of the browsers except the primary (first) one.
*

View File

@@ -6,6 +6,7 @@ use Carbon\Carbon;
use Closure;
use Exception;
use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Exception\ScriptTimeoutException;
use Facebook\WebDriver\Exception\TimeOutException;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Illuminate\Support\Arr;
@@ -18,7 +19,7 @@ trait WaitsForElements
*
* @param string $selector
* @param \Closure $callback
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -29,10 +30,10 @@ trait WaitsForElements
}
/**
* Wait for the given selector to be visible.
* Wait for the given selector to become visible.
*
* @param string $selector
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -50,7 +51,7 @@ trait WaitsForElements
* Wait for the given selector to be removed.
*
* @param string $selector
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -74,7 +75,7 @@ trait WaitsForElements
* Wait for the given text to be removed.
*
* @param string $text
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -91,10 +92,10 @@ trait WaitsForElements
}
/**
* Wait for the given text to be visible.
* Wait for the given text to become visible.
*
* @param array|string $text
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -111,10 +112,29 @@ trait WaitsForElements
}
/**
* Wait for the given link to be visible.
* Wait for the given text to become visible inside the given selector.
*
* @param string $selector
* @param array|string $text
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
*/
public function waitForTextIn($selector, $text, $seconds = null)
{
$message = 'Waited %s seconds for text "'.$text.'" in selector '.$selector;
return $this->waitUsing($seconds, 100, function () use ($selector, $text) {
return $this->assertSeeIn($selector, $text);
}, $message);
}
/**
* Wait for the given link to become visible.
*
* @param string $link
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -128,11 +148,23 @@ trait WaitsForElements
}, $message);
}
/**
* Wait for an input field to become visible.
*
* @param string $field
* @param int|null $seconds
* @return $this
*/
public function waitForInput($field, $seconds = null)
{
return $this->waitFor("input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']", $seconds);
}
/**
* Wait for the given location.
*
* @param string $path
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -141,7 +173,9 @@ trait WaitsForElements
{
$message = $this->formatTimeOutMessage('Waited %s seconds for location', $path);
return $this->waitUntil("window.location.pathname == '{$path}'", $seconds, $message);
return Str::startsWith($path, ['http://', 'https://'])
? $this->waitUntil('`${location.protocol}//${location.host}${location.pathname}` == \''.$path.'\'', $seconds, $message)
: $this->waitUntil("window.location.pathname == '{$path}'", $seconds, $message);
}
/**
@@ -149,7 +183,7 @@ trait WaitsForElements
*
* @param string $route
* @param array $parameters
* @param int $seconds
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -159,12 +193,48 @@ trait WaitsForElements
return $this->waitForLocation(route($route, $parameters, false), $seconds);
}
/**
* Wait until an element is enabled.
*
* @param string $selector
* @param int|null $seconds
* @return $this
*/
public function waitUntilEnabled($selector, $seconds = null)
{
$message = $this->formatTimeOutMessage('Waited %s seconds for element to be enabled', $selector);
$this->waitUsing($seconds, 100, function () use ($selector) {
return $this->resolver->findOrFail($selector)->isEnabled();
}, $message);
return $this;
}
/**
* Wait until an element is disabled.
*
* @param string $selector
* @param int|null $seconds
* @return $this
*/
public function waitUntilDisabled($selector, $seconds = null)
{
$message = $this->formatTimeOutMessage('Waited %s seconds for element to be disabled', $selector);
$this->waitUsing($seconds, 100, function () use ($selector) {
return ! $this->resolver->findOrFail($selector)->isEnabled();
}, $message);
return $this;
}
/**
* Wait until the given script returns true.
*
* @param string $script
* @param int $seconds
* @param string $message
* @param int|null $seconds
* @param string|null $message
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -190,6 +260,7 @@ trait WaitsForElements
* @param string $key
* @param string $value
* @param string|null $componentSelector
* @param int|null $seconds
* @return $this
*/
public function waitUntilVue($key, $value, $componentSelector = null, $seconds = null)
@@ -207,6 +278,7 @@ trait WaitsForElements
* @param string $key
* @param string $value
* @param string|null $componentSelector
* @param int|null $seconds
* @return $this
*/
public function waitUntilVueIsNot($key, $value, $componentSelector = null, $seconds = null)
@@ -221,7 +293,7 @@ trait WaitsForElements
/**
* Wait for a JavaScript dialog to open.
*
* @param int $seconds
* @param int|null $seconds
* @return $this
*/
public function waitForDialog($seconds = null)
@@ -238,8 +310,8 @@ trait WaitsForElements
/**
* Wait for the current page to reload.
*
* @param \Closure $callback
* @param int $seconds
* @param \Closure|null $callback
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
@@ -259,10 +331,55 @@ trait WaitsForElements
}, 'Waited %s seconds for page reload.');
}
/**
* Click an element and wait for the page to reload.
*
* @param string|null $selector
* @return $this
*/
public function clickAndWaitForReload($selector = null)
{
return $this->waitForReload(function ($browser) use ($selector) {
$browser->click($selector);
});
}
/**
* Wait for the given event type to occur on a target.
*
* @param string $type
* @param string|null $target
* @param int|null $seconds
* @return $this
*
* @throws \Facebook\WebDriver\Exception\TimeOutException
*/
public function waitForEvent($type, $target = null, $seconds = null)
{
$seconds = is_null($seconds) ? static::$waitSeconds : $seconds;
if ($target !== 'document' && $target !== 'window') {
$target = $this->resolver->findOrFail($target ?? '');
}
$this->driver->manage()->timeouts()->setScriptTimeout($seconds);
try {
$this->driver->executeAsyncScript(
'eval(arguments[0]).addEventListener(arguments[1], () => arguments[2](), { once: true });',
[$target, $type]
);
} catch (ScriptTimeoutException $e) {
throw new TimeoutException("Waited {$seconds} seconds for event [{$type}].");
}
return $this;
}
/**
* Wait for the given callback to be true.
*
* @param int $seconds
* @param int|null $seconds
* @param int $interval
* @param \Closure $callback
* @param string|null $message

View File

@@ -4,6 +4,7 @@ namespace Laravel\Dusk\Console;
use Illuminate\Console\Command;
use Laravel\Dusk\OperatingSystem;
use Symfony\Component\Process\Process;
use ZipArchive;
/**
@@ -18,6 +19,7 @@ class ChromeDriverCommand extends Command
*/
protected $signature = 'dusk:chrome-driver {version?}
{--all : Install a ChromeDriver binary for every OS}
{--detect : Detect the installed Chrome / Chromium version}
{--proxy= : The proxy to download the binary through (example: "tcp://127.0.0.1:9000")}
{--ssl-no-verify : Bypass SSL certificate verification when installing through a proxy}';
@@ -57,6 +59,8 @@ class ChromeDriverCommand extends Command
protected $slugs = [
'linux' => 'linux64',
'mac' => 'mac64',
'mac-intel' => 'mac64',
'mac-arm' => 'mac_arm64',
'win' => 'win32',
];
@@ -102,6 +106,32 @@ class ChromeDriverCommand extends Command
*/
protected $directory = __DIR__.'/../../bin/';
/**
* The default commands to detect the installed Chrome / Chromium version.
*
* @var array
*/
protected $chromeVersionCommands = [
'linux' => [
'/usr/bin/google-chrome --version',
'/usr/bin/chromium-browser --version',
'/usr/bin/chromium --version',
'/usr/bin/google-chrome-stable --version',
],
'mac' => [
'/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version',
],
'mac-intel' => [
'/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version',
],
'mac-arm' => [
'/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version',
],
'win' => [
'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version',
],
];
/**
* Execute the console command.
*
@@ -139,6 +169,10 @@ class ChromeDriverCommand extends Command
{
$version = $this->argument('version');
if ($this->option('detect')) {
$version = $this->detectChromeVersion(OperatingSystem::id());
}
if (! $version) {
return $this->latestVersion();
}
@@ -165,7 +199,49 @@ class ChromeDriverCommand extends Command
*/
protected function latestVersion()
{
return trim(file_get_contents($this->latestVersionUrl));
$streamOptions = [];
if ($this->option('ssl-no-verify')) {
$streamOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
],
];
}
if ($this->option('proxy')) {
$streamOptions['http'] = ['proxy' => $this->option('proxy'), 'request_fulluri' => true];
}
return trim(file_get_contents($this->latestVersionUrl, false, stream_context_create($streamOptions)));
}
/**
* Detect the installed Chrome / Chromium major version.
*
* @param string $os
* @return int|bool
*/
protected function detectChromeVersion($os)
{
foreach ($this->chromeVersionCommands[$os] as $command) {
$process = Process::fromShellCommandline($command);
$process->run();
preg_match('/(\d+)(\.\d+){3}/', $process->getOutput(), $matches);
if (! isset($matches[1])) {
continue;
}
return $matches[1];
}
$this->error('Chrome version could not be detected.');
return false;
}
/**

View File

@@ -17,7 +17,10 @@ class DuskCommand extends Command
*
* @var string
*/
protected $signature = 'dusk {--without-tty : Disable output to TTY}';
protected $signature = 'dusk
{--browse : Open a browser instead of using headless mode}
{--without-tty : Disable output to TTY}
{--pest : Run the tests using Pest}';
/**
* The console command description.
@@ -56,12 +59,18 @@ class DuskCommand extends Command
$this->purgeConsoleLogs();
$options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2);
$this->purgeSourceLogs();
$options = collect($_SERVER['argv'])
->slice(2)
->diff(['--browse', '--without-tty'])
->values()
->all();
return $this->withDuskEnvironment(function () use ($options) {
$process = (new Process(array_merge(
$this->binary(), $this->phpunitArguments($options)
)))->setTimeout(null);
), null, $this->env()))->setTimeout(null);
try {
$process->setTty(! $this->option('without-tty'));
@@ -88,11 +97,17 @@ class DuskCommand extends Command
*/
protected function binary()
{
if ('phpdbg' === PHP_SAPI) {
return [PHP_BINARY, '-qrr', 'vendor/phpunit/phpunit/phpunit'];
$binaryPath = 'vendor/phpunit/phpunit/phpunit';
if ($this->option('pest')) {
$binaryPath = 'vendor/pestphp/pest/bin/pest';
}
return [PHP_BINARY, 'vendor/phpunit/phpunit/phpunit'];
if ('phpdbg' === PHP_SAPI) {
return [PHP_BINARY, '-qrr', $binaryPath];
}
return [PHP_BINARY, $binaryPath];
}
/**
@@ -104,7 +119,7 @@ class DuskCommand extends Command
protected function phpunitArguments($options)
{
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=');
return ! Str::startsWith($option, ['--env=', '--pest']);
}));
if (! file_exists($file = base_path('phpunit.dusk.xml'))) {
@@ -114,6 +129,18 @@ class DuskCommand extends Command
return array_merge(['-c', $file], $options);
}
/**
* Get the PHP binary environment variables.
*
* @return array|null
*/
protected function env()
{
if ($this->option('browse') && ! isset($_ENV['CI']) && ! isset($_SERVER['CI'])) {
return ['DUSK_HEADLESS_DISABLED' => true];
}
}
/**
* Purge the failure screenshots.
*
@@ -121,19 +148,9 @@ class DuskCommand extends Command
*/
protected function purgeScreenshots()
{
$path = base_path('tests/Browser/screenshots');
if (! is_dir($path)) {
return;
}
$files = Finder::create()->files()
->in($path)
->name('failure-*');
foreach ($files as $file) {
@unlink($file->getRealPath());
}
$this->purgeDebuggingFiles(
base_path('tests/Browser/screenshots'), 'failure-*'
);
}
/**
@@ -143,15 +160,39 @@ class DuskCommand extends Command
*/
protected function purgeConsoleLogs()
{
$path = base_path('tests/Browser/console');
$this->purgeDebuggingFiles(
base_path('tests/Browser/console'), '*.log'
);
}
/**
* Purge the source logs.
*
* @return void
*/
protected function purgeSourceLogs()
{
$this->purgeDebuggingFiles(
base_path('tests/Browser/source'), '*.txt'
);
}
/**
* Purge debugging files based on path and patterns.
*
* @param string $path
* @param string $patterns
* @return void
*/
protected function purgeDebuggingFiles($path, $patterns)
{
if (! is_dir($path)) {
return;
}
$files = Finder::create()->files()
->in($path)
->name('*.log');
->in($path)
->name($patterns);
foreach ($files as $file) {
@unlink($file->getRealPath());

View File

@@ -9,7 +9,10 @@ class DuskFailsCommand extends DuskCommand
*
* @var string
*/
protected $signature = 'dusk:fails {--without-tty : Disable output to TTY}';
protected $signature = 'dusk:fails
{--browse : Open a browser instead of using headless mode}
{--without-tty : Disable output to TTY}
{--pest : Run the tests using Pest}';
/**
* The console command description.

View File

@@ -45,6 +45,10 @@ class InstallCommand extends Command
$this->createConsoleDirectory();
}
if (! is_dir(base_path('tests/Browser/source'))) {
$this->createSourceDirectory();
}
$stubs = [
'ExampleTest.stub' => base_path('tests/Browser/ExampleTest.php'),
'HomePage.stub' => base_path('tests/Browser/Pages/HomePage.php'),
@@ -100,6 +104,20 @@ class InstallCommand extends Command
file_put_contents(base_path('tests/Browser/console/.gitignore'), '*
!.gitignore
');
}
/**
* Create the source directory.
*
* @return void
*/
protected function createSourceDirectory()
{
mkdir(base_path('tests/Browser/source'), 0755, true);
file_put_contents(base_path('tests/Browser/source/.gitignore'), '*
!.gitignore
');
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Laravel\Dusk\Console;
use Illuminate\Console\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
class PurgeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'dusk:purge';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Purge dusk test debugging files';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->ignoreValidationErrors();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->purgeScreenshots();
$this->purgeConsoleLogs();
$this->purgeSourceLogs();
}
/**
* Purge the failure screenshots.
*
* @return void
*/
protected function purgeScreenshots()
{
$this->purgeDebuggingFiles(
'tests/Browser/screenshots', 'failure-*'
);
}
/**
* Purge the console logs.
*
* @return void
*/
protected function purgeConsoleLogs()
{
$this->purgeDebuggingFiles(
'tests/Browser/console', '*.log'
);
}
/**
* Purge the source logs.
*
* @return void
*/
protected function purgeSourceLogs()
{
$this->purgeDebuggingFiles(
'tests/Browser/source', '*.txt'
);
}
/**
* Purge debugging files based on path and patterns.
*
* @param string $relativePath
* @param string $patterns
* @return void
*/
protected function purgeDebuggingFiles($relativePath, $patterns)
{
$path = base_path($relativePath);
if (! is_dir($path)) {
$this->warn(
"Unable to purge missing directory [{$relativePath}].", OutputInterface::VERBOSITY_DEBUG
);
return;
}
$files = Finder::create()->files()
->in($path)
->name($patterns);
foreach ($files as $file) {
@unlink($file->getRealPath());
}
$this->info("Purged \"{$patterns}\" from [{$relativePath}].");
}
}

View File

@@ -3,6 +3,7 @@
namespace DummyNamespace;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Page;
class DummyClass extends Page
{

View File

@@ -15,32 +15,28 @@ class DuskServiceProvider extends ServiceProvider
public function boot()
{
if (! $this->app->environment('production')) {
Route::get('/_dusk/login/{userId}/{guard?}', [
'middleware' => 'web',
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@login',
]);
Route::group(array_filter([
'prefix' => config('dusk.path', '_dusk'),
'domain' => config('dusk.domain', null),
'middleware' => config('dusk.middleware', 'web'),
]), function () {
Route::get('/login/{userId}/{guard?}', [
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@login',
'as' => 'dusk.login',
]);
Route::get('/_dusk/logout/{guard?}', [
'middleware' => 'web',
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@logout',
]);
Route::get('/logout/{guard?}', [
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@logout',
'as' => 'dusk.logout',
]);
Route::get('/_dusk/user/{guard?}', [
'middleware' => 'web',
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@user',
]);
Route::get('/user/{guard?}', [
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@user',
'as' => 'dusk.user',
]);
});
}
}
/**
* Register any package services.
*
* @return void
*
* @throws \Exception
*/
public function register()
{
if ($this->app->runningInConsole()) {
$this->commands([
Console\InstallCommand::class,
@@ -48,6 +44,7 @@ class DuskServiceProvider extends ServiceProvider
Console\DuskFailsCommand::class,
Console\MakeCommand::class,
Console\PageCommand::class,
Console\PurgeCommand::class,
Console\ComponentCommand::class,
Console\ChromeDriverCommand::class,
]);

View File

@@ -87,7 +87,7 @@ class ElementResolver
}
return $this->firstOrFail([
$field, "input[name='{$field}']", "textarea[name='{$field}']",
"input[name='{$field}']", "textarea[name='{$field}']", $field,
]);
}
@@ -106,7 +106,7 @@ class ElementResolver
}
return $this->firstOrFail([
$field, "select[name='{$field}']",
"select[name='{$field}']", $field,
]);
}
@@ -137,7 +137,7 @@ class ElementResolver
* Resolve the element for a given radio "field" / value.
*
* @param string $field
* @param string $value
* @param string|null $value
* @return \Facebook\WebDriver\Remote\RemoteWebElement
*
* @throws \Exception
@@ -156,7 +156,7 @@ class ElementResolver
}
return $this->firstOrFail([
$field, "input[type=radio][name='{$field}'][value='{$value}']",
"input[type=radio][name='{$field}'][value='{$value}']", $field,
]);
}
@@ -164,7 +164,7 @@ class ElementResolver
* Resolve the element for a given checkbox "field".
*
* @param string|null $field
* @param string $value
* @param string|null $value
* @return \Facebook\WebDriver\Remote\RemoteWebElement
*
* @throws \Exception
@@ -186,7 +186,7 @@ class ElementResolver
}
return $this->firstOrFail([
$field, $selector,
$selector, $field,
]);
}
@@ -205,7 +205,7 @@ class ElementResolver
}
return $this->firstOrFail([
$field, "input[type=file][name='{$field}']",
"input[type=file][name='{$field}']", $field,
]);
}
@@ -224,8 +224,8 @@ class ElementResolver
}
return $this->firstOrFail([
$field, "input[name='{$field}']", "textarea[name='{$field}']",
"select[name='{$field}']", "button[name='{$field}']",
"input[name='{$field}']", "textarea[name='{$field}']",
"select[name='{$field}']", "button[name='{$field}']", $field,
]);
}

View File

@@ -3,6 +3,7 @@
namespace Laravel\Dusk\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
class UserController
@@ -31,7 +32,7 @@ class UserController
* Login using the given user ID / email.
*
* @param string $userId
* @param string $guard
* @param string|null $guard
* @return void
*/
public function login($userId, $guard = null)
@@ -50,12 +51,16 @@ class UserController
/**
* Log the user out of the application.
*
* @param string $guard
* @param string|null $guard
* @return void
*/
public function logout($guard = null)
{
Auth::guard($guard ?: config('auth.defaults.guard'))->logout();
$guard = $guard ?: config('auth.defaults.guard');
Auth::guard($guard)->logout();
Session::forget('password_hash_'.$guard);
}
/**

View File

@@ -13,7 +13,13 @@ class OperatingSystem
*/
public static function id()
{
return static::onWindows() ? 'win' : (static::onMac() ? 'mac' : 'linux');
if (static::onWindows()) {
return 'win';
} elseif (static::onMac()) {
return static::macArchitectureId();
}
return 'linux';
}
/**
@@ -35,4 +41,21 @@ class OperatingSystem
{
return PHP_OS === 'Darwin';
}
/**
* Mac platform architecture.
*
* @return string
*/
public static function macArchitectureId()
{
switch (php_uname('m')) {
case 'arm64':
return 'mac-arm';
case 'x86_64':
return 'mac-intel';
default:
return 'mac';
}
}
}

View File

@@ -54,7 +54,7 @@ abstract class TestCase extends FoundationTestCase
*/
protected function baseUrl()
{
return config('app.url');
return rtrim(config('app.url'), '/');
}
/**
@@ -68,4 +68,14 @@ abstract class TestCase extends FoundationTestCase
{
throw new Exception('User resolver has not been set.');
}
/**
* Determine if the tests are running within Laravel Sail.
*
* @return bool
*/
protected static function runningInSail()
{
return isset($_ENV['LARAVEL_SAIL']) && $_ENV['LARAVEL_SAIL'] == '1';
}
}

View File

@@ -19,7 +19,9 @@ abstract class DuskTestCase extends BaseTestCase
*/
public static function prepare()
{
static::startChromeDriver();
if (! static::runningInSail()) {
static::startChromeDriver();
}
}
/**
@@ -29,16 +31,42 @@ abstract class DuskTestCase extends BaseTestCase
*/
protected function driver()
{
$options = (new ChromeOptions)->addArguments([
'--disable-gpu',
'--headless',
'--window-size=1920,1080',
]);
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
])->unless($this->hasHeadlessDisabled(), function ($items) {
return $items->merge([
'--disable-gpu',
'--headless',
]);
})->all());
return RemoteWebDriver::create(
'http://localhost:9515', DesiredCapabilities::chrome()->setCapability(
$_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
/**
* Determine whether the Dusk command has disabled headless mode.
*
* @return bool
*/
protected function hasHeadlessDisabled()
{
return isset($_SERVER['DUSK_HEADLESS_DISABLED']) ||
isset($_ENV['DUSK_HEADLESS_DISABLED']);
}
/**
* Determine if the browser window should start maximized.
*
* @return bool
*/
protected function shouldStartMaximized()
{
return isset($_SERVER['DUSK_START_MAXIMIZED']) ||
isset($_ENV['DUSK_START_MAXIMIZED']);
}
}

View File

@@ -13,9 +13,4 @@
<directory suffix="Test.php">./tests/Browser</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./app</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -2,9 +2,9 @@
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/v/stable.svg" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/license.svg" alt="License"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel

View File

@@ -15,41 +15,43 @@
}
],
"require": {
"php": "^7.2.5|^8.0",
"php": "^7.3|^8.0",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"doctrine/inflector": "^1.4|^2.0",
"dragonmantank/cron-expression": "^2.3.1",
"dragonmantank/cron-expression": "^3.0.2",
"egulias/email-validator": "^2.1.10",
"league/commonmark": "^1.3",
"laravel/serializable-closure": "^1.0",
"league/commonmark": "^1.3|^2.0.2",
"league/flysystem": "^1.1",
"monolog/monolog": "^2.0",
"nesbot/carbon": "^2.31",
"nesbot/carbon": "^2.53.1",
"opis/closure": "^3.6",
"psr/container": "^1.0",
"psr/log": "^1.0|^2.0",
"psr/simple-cache": "^1.0",
"ramsey/uuid": "^3.7|^4.0",
"swiftmailer/swiftmailer": "^6.0",
"symfony/console": "^5.0",
"symfony/error-handler": "^5.0",
"symfony/finder": "^5.0",
"symfony/http-foundation": "^5.0",
"symfony/http-kernel": "^5.0",
"symfony/mime": "^5.0",
"symfony/polyfill-php73": "^1.17",
"symfony/process": "^5.0",
"symfony/routing": "^5.0",
"symfony/var-dumper": "^5.0",
"ramsey/uuid": "^4.2.2",
"swiftmailer/swiftmailer": "^6.3",
"symfony/console": "^5.4",
"symfony/error-handler": "^5.4",
"symfony/finder": "^5.4",
"symfony/http-foundation": "^5.4",
"symfony/http-kernel": "^5.4",
"symfony/mime": "^5.4",
"symfony/process": "^5.4",
"symfony/routing": "^5.4",
"symfony/var-dumper": "^5.4",
"tijsverkoyen/css-to-inline-styles": "^2.2.2",
"vlucas/phpdotenv": "^4.0",
"voku/portable-ascii": "^1.4.8"
"vlucas/phpdotenv": "^5.4.1",
"voku/portable-ascii": "^1.6.1"
},
"replace": {
"illuminate/auth": "self.version",
"illuminate/broadcasting": "self.version",
"illuminate/bus": "self.version",
"illuminate/cache": "self.version",
"illuminate/collections": "self.version",
"illuminate/config": "self.version",
"illuminate/console": "self.version",
"illuminate/container": "self.version",
@@ -62,6 +64,7 @@
"illuminate/hashing": "self.version",
"illuminate/http": "self.version",
"illuminate/log": "self.version",
"illuminate/macroable": "self.version",
"illuminate/mail": "self.version",
"illuminate/notifications": "self.version",
"illuminate/pagination": "self.version",
@@ -77,32 +80,35 @@
"illuminate/view": "self.version"
},
"require-dev": {
"aws/aws-sdk-php": "^3.155",
"doctrine/dbal": "^2.6",
"filp/whoops": "^2.8",
"guzzlehttp/guzzle": "^6.3.1|^7.0.1",
"aws/aws-sdk-php": "^3.198.1",
"doctrine/dbal": "^2.13.3|^3.1.4",
"filp/whoops": "^2.14.3",
"guzzlehttp/guzzle": "^6.5.5|^7.0.1",
"league/flysystem-cached-adapter": "^1.0",
"mockery/mockery": "~1.3.3|^1.4.2",
"moontoast/math": "^1.1",
"orchestra/testbench-core": "^5.8",
"mockery/mockery": "^1.4.4",
"orchestra/testbench-core": "^6.27",
"pda/pheanstalk": "^4.0",
"phpunit/phpunit": "^8.4|^9.3.3",
"predis/predis": "^1.1.1",
"symfony/cache": "^5.0"
"phpunit/phpunit": "^8.5.19|^9.5.8",
"predis/predis": "^1.1.9",
"symfony/cache": "^5.4"
},
"provide": {
"psr/container-implementation": "1.0"
"psr/container-implementation": "1.0",
"psr/simple-cache-implementation": "1.0"
},
"conflict": {
"tightenco/collect": "<5.5.33"
},
"autoload": {
"files": [
"src/Illuminate/Collections/helpers.php",
"src/Illuminate/Events/functions.php",
"src/Illuminate/Foundation/helpers.php",
"src/Illuminate/Support/helpers.php"
],
"psr-4": {
"Illuminate\\": "src/Illuminate/"
"Illuminate\\": "src/Illuminate/",
"Illuminate\\Support\\": ["src/Illuminate/Macroable/", "src/Illuminate/Collections/"]
}
},
"autoload-dev": {
@@ -115,40 +121,45 @@
},
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
"dev-master": "8.x-dev"
}
},
"suggest": {
"ext-bcmath": "Required to use the multiple_of validation rule.",
"ext-ftp": "Required to use the Flysystem FTP driver.",
"ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().",
"ext-memcached": "Required to use the memcache cache driver.",
"ext-pcntl": "Required to use all features of the queue worker.",
"ext-posix": "Required to use all features of the queue worker.",
"ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).",
"aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.155).",
"doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.6).",
"filp/whoops": "Required for friendly error pages in development (^2.8).",
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
"aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.198.1).",
"brianium/paratest": "Required to run tests in parallel (^6.0).",
"doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).",
"filp/whoops": "Required for friendly error pages in development (^2.14.3).",
"fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).",
"guzzlehttp/guzzle": "Required to use the HTTP Client, Mailgun mail driver and the ping methods on schedules (^6.3.1|^7.0.1).",
"guzzlehttp/guzzle": "Required to use the HTTP Client, Mailgun mail driver and the ping methods on schedules (^6.5.5|^7.0.1).",
"laravel/tinker": "Required to use the tinker console command (^2.0).",
"league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0).",
"league/flysystem-cached-adapter": "Required to use the Flysystem cache (^1.0).",
"league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).",
"mockery/mockery": "Required to use mocking (~1.3.3|^1.4.2).",
"moontoast/math": "Required to use ordered UUIDs (^1.1).",
"mockery/mockery": "Required to use mocking (^1.4.4).",
"nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).",
"pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).",
"phpunit/phpunit": "Required to use assertions and run tests (^8.4|^9.3.3).",
"predis/predis": "Required to use the predis connector (^1.1.2).",
"phpunit/phpunit": "Required to use assertions and run tests (^8.5.19|^9.5.8).",
"predis/predis": "Required to use the predis connector (^1.1.9).",
"psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
"pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^5.0).",
"symfony/filesystem": "Required to create relative storage directory symbolic links (^5.0).",
"pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0|^5.0|^6.0|^7.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^5.4).",
"symfony/filesystem": "Required to enable support for relative symbolic links (^5.4).",
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0).",
"wildbit/swiftmailer-postmark": "Required to use Postmark mail driver (^3.0)."
},
"config": {
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": true
}
},
"minimum-stability": "dev",
"prefer-stable": true

View File

@@ -0,0 +1,51 @@
<?php
namespace Illuminate\Auth\Access\Events;
class GateEvaluated
{
/**
* The authenticatable model.
*
* @var \Illuminate\Contracts\Auth\Authenticatable|null
*/
public $user;
/**
* The ability being evaluated.
*
* @var string
*/
public $ability;
/**
* The result of the evaluation.
*
* @var bool|null
*/
public $result;
/**
* The arguments given during evaluation.
*
* @var array
*/
public $arguments;
/**
* Create a new event instance.
*
* @param \Illuminate\Contracts\Auth\Authenticatable|null $user
* @param string $ability
* @param bool|null $result
* @param array $arguments
* @return void
*/
public function __construct($user, $ability, $result, $arguments)
{
$this->user = $user;
$this->ability = $ability;
$this->result = $result;
$this->arguments = $arguments;
}
}

View File

@@ -2,10 +2,13 @@
namespace Illuminate\Auth\Access;
use Closure;
use Exception;
use Illuminate\Contracts\Auth\Access\Gate as GateContract;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
use ReflectionClass;
@@ -115,6 +118,64 @@ class Gate implements GateContract
return true;
}
/**
* Perform an on-demand authorization check. Throw an authorization exception if the condition or callback is false.
*
* @param \Illuminate\Auth\Access\Response|\Closure|bool $condition
* @param string|null $message
* @param string|null $code
* @return \Illuminate\Auth\Access\Response
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function allowIf($condition, $message = null, $code = null)
{
return $this->authorizeOnDemand($condition, $message, $code, true);
}
/**
* Perform an on-demand authorization check. Throw an authorization exception if the condition or callback is true.
*
* @param \Illuminate\Auth\Access\Response|\Closure|bool $condition
* @param string|null $message
* @param string|null $code
* @return \Illuminate\Auth\Access\Response
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function denyIf($condition, $message = null, $code = null)
{
return $this->authorizeOnDemand($condition, $message, $code, false);
}
/**
* Authorize a given condition or callback.
*
* @param \Illuminate\Auth\Access\Response|\Closure|bool $condition
* @param string|null $message
* @param string|null $code
* @param bool $allowWhenResponseIs
* @return \Illuminate\Auth\Access\Response
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
protected function authorizeOnDemand($condition, $message, $code, $allowWhenResponseIs)
{
$user = $this->resolveUser();
if ($condition instanceof Closure) {
$response = $this->canBeCalledWithUser($user, $condition)
? $condition($user)
: new Response(false, $message, $code);
} else {
$response = $condition;
}
return with($response instanceof Response ? $response : new Response(
(bool) $response === $allowWhenResponseIs, $message, $code
))->authorize();
}
/**
* Define a new ability.
*
@@ -155,10 +216,10 @@ class Gate implements GateContract
{
$abilities = $abilities ?: [
'viewAny' => 'viewAny',
'view' => 'view',
'create' => 'create',
'update' => 'update',
'delete' => 'delete',
'view' => 'view',
'create' => 'create',
'update' => 'update',
'delete' => 'delete',
];
foreach ($abilities as $ability => $method) {
@@ -373,9 +434,11 @@ class Gate implements GateContract
// After calling the authorization callback, we will call the "after" callbacks
// that are registered with the Gate, which allows a developer to do logging
// if that is required for this application. Then we'll return the result.
return $this->callAfterCallbacks(
return tap($this->callAfterCallbacks(
$user, $ability, $arguments, $result
);
), function ($result) use ($user, $ability, $arguments) {
$this->dispatchGateEvaluatedEvent($user, $ability, $arguments, $result);
});
}
/**
@@ -518,6 +581,24 @@ class Gate implements GateContract
return $result;
}
/**
* Dispatch a gate evaluation event.
*
* @param \Illuminate\Contracts\Auth\Authenticatable|null $user
* @param string $ability
* @param array $arguments
* @param bool|null $result
* @return void
*/
protected function dispatchGateEvaluatedEvent($user, $ability, array $arguments, $result)
{
if ($this->container->bound(Dispatcher::class)) {
$this->container->make(Dispatcher::class)->dispatch(
new Events\GateEvaluated($user, $ability, $result, $arguments)
);
}
}
/**
* Resolve the callable for the given ability and arguments.
*
@@ -599,7 +680,15 @@ class Gate implements GateContract
$classDirname = str_replace('/', '\\', dirname(str_replace('\\', '/', $class)));
return [$classDirname.'\\Policies\\'.class_basename($class).'Policy'];
$classDirnameSegments = explode('\\', $classDirname);
return Arr::wrap(Collection::times(count($classDirnameSegments), function ($index) use ($class, $classDirnameSegments) {
$classDirname = implode('\\', array_slice($classDirnameSegments, 0, $index));
return $classDirname.'\\Policies\\'.class_basename($class).'Policy';
})->reverse()->values()->first(function ($class) {
return class_exists($class);
}) ?: [$classDirname.'\\Policies\\'.class_basename($class).'Policy']);
}
/**
@@ -770,4 +859,17 @@ class Gate implements GateContract
{
return $this->policies;
}
/**
* Set the container instance used by the gate.
*
* @param \Illuminate\Contracts\Container\Container $container
* @return $this
*/
public function setContainer(Container $container)
{
$this->container = $container;
return $this;
}
}

View File

@@ -122,7 +122,11 @@ class AuthManager implements FactoryContract
{
$provider = $this->createUserProvider($config['provider'] ?? null);
$guard = new SessionGuard($name, $provider, $this->app['session.store']);
$guard = new SessionGuard(
$name,
$provider,
$this->app['session.store'],
);
// When using the remember me functionality of the authentication services we
// will need to be set the encryption instance of the guard, which allows
@@ -139,6 +143,10 @@ class AuthManager implements FactoryContract
$guard->setRequest($this->app->refresh('request', $guard, 'setRequest'));
}
if (isset($config['remember'])) {
$guard->setRememberDuration($config['remember']);
}
return $guard;
}
@@ -295,6 +303,31 @@ class AuthManager implements FactoryContract
return count($this->guards) > 0;
}
/**
* Forget all of the resolved guard instances.
*
* @return $this
*/
public function forgetGuards()
{
$this->guards = [];
return $this;
}
/**
* Set the application instance used by the manager.
*
* @param \Illuminate\Contracts\Foundation\Application $app
* @return $this
*/
public function setApplication($app)
{
$this->app = $app;
return $this;
}
/**
* Dynamically call the default driver instance.
*

View File

@@ -35,11 +35,6 @@ class AuthServiceProvider extends ServiceProvider
protected function registerAuthenticator()
{
$this->app->singleton('auth', function ($app) {
// Once the authentication service has actually been requested by the developer
// we will set a variable in the application indicating such. This helps us
// know that we need to set any queued cookies in the after event later.
$app['auth.loaded'] = true;
return new AuthManager($app);
});
@@ -55,11 +50,9 @@ class AuthServiceProvider extends ServiceProvider
*/
protected function registerUserResolver()
{
$this->app->bind(
AuthenticatableContract::class, function ($app) {
return call_user_func($app['auth']->userResolver());
}
);
$this->app->bind(AuthenticatableContract::class, function ($app) {
return call_user_func($app['auth']->userResolver());
});
}
/**
@@ -83,15 +76,13 @@ class AuthServiceProvider extends ServiceProvider
*/
protected function registerRequirePassword()
{
$this->app->bind(
RequirePassword::class, function ($app) {
return new RequirePassword(
$app[ResponseFactory::class],
$app[UrlGenerator::class],
$app['config']->get('auth.password_timeout')
);
}
);
$this->app->bind(RequirePassword::class, function ($app) {
return new RequirePassword(
$app[ResponseFactory::class],
$app[UrlGenerator::class],
$app['config']->get('auth.password_timeout')
);
});
}
/**
@@ -116,11 +107,8 @@ class AuthServiceProvider extends ServiceProvider
protected function registerEventRebindHandler()
{
$this->app->rebinding('events', function ($app, $dispatcher) {
if (! $app->resolved('auth')) {
return;
}
if ($app['auth']->hasResolvedGuards() === false) {
if (! $app->resolved('auth') ||
$app['auth']->hasResolvedGuards() === false) {
return;
}

View File

@@ -31,6 +31,16 @@ trait Authenticatable
return $this->{$this->getAuthIdentifierName()};
}
/**
* Get the unique broadcast identifier for the user.
*
* @return mixed
*/
public function getAuthIdentifierForBroadcasting()
{
return $this->getAuthIdentifier();
}
/**
* Get the password for the user.
*

View File

@@ -16,7 +16,7 @@ class AuthenticationException extends Exception
/**
* The path the user should be redirected to.
*
* @var string
* @var string|null
*/
protected $redirectTo;
@@ -49,7 +49,7 @@ class AuthenticationException extends Exception
/**
* Get the path the user should be redirected to.
*
* @return string
* @return string|null
*/
public function redirectTo()
{

View File

@@ -13,8 +13,8 @@
<script src="{{ asset('js/app.js') }}" defer></script>
<!-- Fonts -->
<link rel="dns-prefetch" href="//fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet">
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">

View File

@@ -2,6 +2,7 @@
namespace Illuminate\Auth;
use Closure;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
@@ -117,6 +118,8 @@ class DatabaseUserProvider implements UserProvider
if (is_array($value) || $value instanceof Arrayable) {
$query->whereIn($key, $value);
} elseif ($value instanceof Closure) {
$value($query);
} else {
$query->where($key, $value);
}

View File

@@ -2,6 +2,7 @@
namespace Illuminate\Auth;
use Closure;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
@@ -123,6 +124,8 @@ class EloquentUserProvider implements UserProvider
if (is_array($value) || $value instanceof Arrayable) {
$query->whereIn($key, $value);
} elseif ($value instanceof Closure) {
$value($query);
} else {
$query->where($key, $value);
}

View File

@@ -25,7 +25,7 @@ trait GuardHelpers
protected $provider;
/**
* Determine if current user is authenticated. If not, throw an exception.
* Determine if the current user is authenticated. If not, throw an exception.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*

View File

@@ -5,6 +5,7 @@ namespace Illuminate\Auth\Middleware;
use Closure;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL;
class EnsureEmailIsVerified
{
@@ -14,7 +15,7 @@ class EnsureEmailIsVerified
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $redirectToRoute
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse|null
*/
public function handle($request, Closure $next, $redirectToRoute = null)
{
@@ -23,7 +24,7 @@ class EnsureEmailIsVerified
! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()
? abort(403, 'Your email address is not verified.')
: Redirect::route($redirectToRoute ?: 'verification.notice');
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));
}
return $next($request);

View File

@@ -63,15 +63,17 @@ class ResetPassword extends Notification
return call_user_func(static::$toMailCallback, $notifiable, $this->token);
}
if (static::$createUrlCallback) {
$url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
} else {
$url = url(route('password.reset', [
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
], false));
}
return $this->buildMailMessage($this->resetUrl($notifiable));
}
/**
* Get the reset password notification mail message for the given URL.
*
* @param string $url
* @return \Illuminate\Notifications\Messages\MailMessage
*/
protected function buildMailMessage($url)
{
return (new MailMessage)
->subject(Lang::get('Reset Password Notification'))
->line(Lang::get('You are receiving this email because we received a password reset request for your account.'))
@@ -80,6 +82,24 @@ class ResetPassword extends Notification
->line(Lang::get('If you did not request a password reset, no further action is required.'));
}
/**
* Get the reset URL for the given notifiable.
*
* @param mixed $notifiable
* @return string
*/
protected function resetUrl($notifiable)
{
if (static::$createUrlCallback) {
return call_user_func(static::$createUrlCallback, $notifiable, $this->token);
}
return url(route('password.reset', [
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
], false));
}
/**
* Set a callback that should be used when creating the reset password button URL.
*

View File

@@ -11,6 +11,13 @@ use Illuminate\Support\Facades\URL;
class VerifyEmail extends Notification
{
/**
* The callback that should be used to create the verify email URL.
*
* @var \Closure|null
*/
public static $createUrlCallback;
/**
* The callback that should be used to build the mail message.
*
@@ -43,10 +50,21 @@ class VerifyEmail extends Notification
return call_user_func(static::$toMailCallback, $notifiable, $verificationUrl);
}
return $this->buildMailMessage($verificationUrl);
}
/**
* Get the verify email notification mail message for the given URL.
*
* @param string $url
* @return \Illuminate\Notifications\Messages\MailMessage
*/
protected function buildMailMessage($url)
{
return (new MailMessage)
->subject(Lang::get('Verify Email Address'))
->line(Lang::get('Please click the button below to verify your email address.'))
->action(Lang::get('Verify Email Address'), $verificationUrl)
->action(Lang::get('Verify Email Address'), $url)
->line(Lang::get('If you did not create an account, no further action is required.'));
}
@@ -58,6 +76,10 @@ class VerifyEmail extends Notification
*/
protected function verificationUrl($notifiable)
{
if (static::$createUrlCallback) {
return call_user_func(static::$createUrlCallback, $notifiable);
}
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
@@ -68,6 +90,17 @@ class VerifyEmail extends Notification
);
}
/**
* Set a callback that should be used when creating the email verification URL.
*
* @param \Closure $callback
* @return void
*/
public static function createUrlUsing($callback)
{
static::$createUrlCallback = $callback;
}
/**
* Set a callback that should be used when building the notification mail message.
*

View File

@@ -42,9 +42,10 @@ class PasswordBroker implements PasswordBrokerContract
* Send a password reset link to a user.
*
* @param array $credentials
* @param \Closure|null $callback
* @return string
*/
public function sendResetLink(array $credentials)
public function sendResetLink(array $credentials, Closure $callback = null)
{
// First we will check to see if we found a user at the given credentials and
// if we did not we will redirect back to this current URI with a piece of
@@ -59,12 +60,16 @@ class PasswordBroker implements PasswordBrokerContract
return static::RESET_THROTTLED;
}
// Once we have the reset token, we are ready to send the message out to this
// user with a link to reset their password. We will then redirect back to
// the current URI having nothing set in the session to indicate errors.
$user->sendPasswordResetNotification(
$this->tokens->create($user)
);
$token = $this->tokens->create($user);
if ($callback) {
$callback($user, $token);
} else {
// Once we have the reset token, we are ready to send the message out to this
// user with a link to reset their password. We will then redirect back to
// the current URI having nothing set in the session to indicate errors.
$user->sendPasswordResetNotification($token);
}
return static::RESET_LINK_SENT;
}

View File

@@ -17,9 +17,12 @@ use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Cookie\QueueingFactory as CookieJar;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Timebox;
use Illuminate\Support\Traits\Macroable;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
@@ -29,7 +32,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
use GuardHelpers, Macroable;
/**
* The name of the Guard. Typically "session".
* The name of the guard. Typically "web".
*
* Corresponds to guard name in authentication configuration.
*
@@ -51,6 +54,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
*/
protected $viaRemember = false;
/**
* The number of minutes that the "remember me" cookie should be valid for.
*
* @var int
*/
protected $rememberDuration = 2628000;
/**
* The session used by the guard.
*
@@ -79,6 +89,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
*/
protected $events;
/**
* The timebox instance.
*
* @var \Illuminate\Support\Timebox
*/
protected $timebox;
/**
* Indicates if the logout method has been called.
*
@@ -100,17 +117,20 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
* @param \Illuminate\Contracts\Auth\UserProvider $provider
* @param \Illuminate\Contracts\Session\Session $session
* @param \Symfony\Component\HttpFoundation\Request|null $request
* @param \Illuminate\Support\Timebox|null $timebox
* @return void
*/
public function __construct($name,
UserProvider $provider,
Session $session,
Request $request = null)
Request $request = null,
Timebox $timebox = null)
{
$this->name = $name;
$this->session = $session;
$this->request = $request;
$this->provider = $provider;
$this->timebox = $timebox ?: new Timebox;
}
/**
@@ -320,7 +340,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
}
/**
* Get the credential array for a HTTP Basic request.
* Get the credential array for an HTTP Basic request.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* @param string $field
@@ -373,6 +393,34 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
return false;
}
/**
* Attempt to authenticate a user with credentials and additional callbacks.
*
* @param array $credentials
* @param array|callable $callbacks
* @param false $remember
* @return bool
*/
public function attemptWhen(array $credentials = [], $callbacks = null, $remember = false)
{
$this->fireAttemptEvent($credentials, $remember);
$this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);
// This method does the exact same thing as attempt, but also executes callbacks after
// the user is retrieved and validated. If one of the callbacks returns falsy we do
// not login the user. Instead, we will fail the specific authentication attempt.
if ($this->hasValidCredentials($user, $credentials) && $this->shouldLogin($callbacks, $user)) {
$this->login($user, $remember);
return true;
}
$this->fireFailedEvent($user, $credentials);
return false;
}
/**
* Determine if the user matches the credentials.
*
@@ -382,13 +430,35 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
*/
protected function hasValidCredentials($user, $credentials)
{
$validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
return $this->timebox->call(function ($timebox) use ($user, $credentials) {
$validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
if ($validated) {
$this->fireValidatedEvent($user);
if ($validated) {
$timebox->returnEarly();
$this->fireValidatedEvent($user);
}
return $validated;
}, 200 * 1000);
}
/**
* Determine if the user should login by executing the given callbacks.
*
* @param array|callable|null $callbacks
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @return bool
*/
protected function shouldLogin($callbacks, AuthenticatableContract $user)
{
foreach (Arr::wrap($callbacks) as $callback) {
if (! $callback($user, $this)) {
return false;
}
}
return $validated;
return true;
}
/**
@@ -484,7 +554,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
*/
protected function createRecaller($value)
{
return $this->getCookieJar()->forever($this->getRecallerName(), $value);
return $this->getCookieJar()->make($this->getRecallerName(), $value, $this->getRememberDuration());
}
/**
@@ -517,6 +587,34 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
$this->loggedOut = true;
}
/**
* Log the user out of the application on their current device only.
*
* This method does not cycle the "remember" token.
*
* @return void
*/
public function logoutCurrentDevice()
{
$user = $this->user();
$this->clearUserDataFromStorage();
// If we have an event dispatcher instance, we can fire off the logout event
// so any further processing can be done. This allows the developer to be
// listening for anytime a user signs out of this application manually.
if (isset($this->events)) {
$this->events->dispatch(new CurrentDeviceLogout($this->name, $user));
}
// Once we have fired the logout event we will clear the users out of memory
// so they are no longer available as the user is no longer considered as
// being signed into this application and should not be available here.
$this->user = null;
$this->loggedOut = true;
}
/**
* Remove the user data from the session and cookies.
*
@@ -545,32 +643,6 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
$this->provider->updateRememberToken($user, $token);
}
/**
* Log the user out of the application on their current device only.
*
* @return void
*/
public function logoutCurrentDevice()
{
$user = $this->user();
$this->clearUserDataFromStorage();
// If we have an event dispatcher instance, we can fire off the logout event
// so any further processing can be done. This allows the developer to be
// listening for anytime a user signs out of this application manually.
if (isset($this->events)) {
$this->events->dispatch(new CurrentDeviceLogout($this->name, $user));
}
// Once we have fired the logout event we will clear the users out of memory
// so they are no longer available as the user is no longer considered as
// being signed into this application and should not be available here.
$this->user = null;
$this->loggedOut = true;
}
/**
* Invalidate other sessions for the current user.
*
@@ -578,7 +650,9 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
*
* @param string $password
* @param string $attribute
* @return bool|null
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*
* @throws \Illuminate\Auth\AuthenticationException
*/
public function logoutOtherDevices($password, $attribute = 'password')
{
@@ -586,9 +660,7 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
return;
}
$result = tap($this->user()->forceFill([
$attribute => Hash::make($password),
]))->save();
$result = $this->rehashUserPassword($password, $attribute);
if ($this->recaller() ||
$this->getCookieJar()->hasQueued($this->getRecallerName())) {
@@ -600,6 +672,26 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
return $result;
}
/**
* Rehash the current user's password.
*
* @param string $password
* @param string $attribute
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*
* @throws \InvalidArgumentException
*/
protected function rehashUserPassword($password, $attribute)
{
if (! Hash::check($password, $this->user()->{$attribute})) {
throw new InvalidArgumentException('The given password does not match the current password.');
}
return tap($this->user()->forceFill([
$attribute => Hash::make($password),
]))->save();
}
/**
* Register an authentication attempt event listener.
*
@@ -746,6 +838,29 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
return $this->viaRemember;
}
/**
* Get the number of minutes the remember me cookie should be valid for.
*
* @return int
*/
protected function getRememberDuration()
{
return $this->rememberDuration;
}
/**
* Set the number of minutes the remember me cookie should be valid for.
*
* @param int $minutes
* @return $this
*/
public function setRememberDuration($minutes)
{
$this->rememberDuration = $minutes;
return $this;
}
/**
* Get the cookie creator instance used by the guard.
*
@@ -853,4 +968,14 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
return $this;
}
/**
* Get the timebox instance used by the guard.
*
* @return \Illuminate\Support\Timebox
*/
public function getTimebox()
{
return $this->timebox;
}
}

View File

@@ -14,11 +14,13 @@
}
],
"require": {
"php": "^7.2.5|^8.0",
"illuminate/contracts": "^7.0",
"illuminate/http": "^7.0",
"illuminate/queue": "^7.0",
"illuminate/support": "^7.0"
"php": "^7.3|^8.0",
"illuminate/collections": "^8.0",
"illuminate/contracts": "^8.0",
"illuminate/http": "^8.0",
"illuminate/macroable": "^8.0",
"illuminate/queue": "^8.0",
"illuminate/support": "^8.0"
},
"autoload": {
"psr-4": {
@@ -27,13 +29,13 @@
},
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
"dev-master": "8.x-dev"
}
},
"suggest": {
"illuminate/console": "Required to use the auth:clear-resets command (^7.0).",
"illuminate/queue": "Required to fire login / logout events (^7.0).",
"illuminate/session": "Required to use the session based guard (^7.0)."
"illuminate/console": "Required to use the auth:clear-resets command (^8.0).",
"illuminate/queue": "Required to fire login / logout events (^8.0).",
"illuminate/session": "Required to use the session based guard (^8.0)."
},
"config": {
"sort-packages": true

View File

@@ -3,7 +3,7 @@
namespace Illuminate\Broadcasting;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Broadcasting\Broadcaster;
use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
@@ -46,23 +46,37 @@ class BroadcastEvent implements ShouldQueue
$this->event = $event;
$this->tries = property_exists($event, 'tries') ? $event->tries : null;
$this->timeout = property_exists($event, 'timeout') ? $event->timeout : null;
$this->afterCommit = property_exists($event, 'afterCommit') ? $event->afterCommit : null;
}
/**
* Handle the queued job.
*
* @param \Illuminate\Contracts\Broadcasting\Broadcaster $broadcaster
* @param \Illuminate\Contracts\Broadcasting\Factory $manager
* @return void
*/
public function handle(Broadcaster $broadcaster)
public function handle(BroadcastingFactory $manager)
{
$name = method_exists($this->event, 'broadcastAs')
? $this->event->broadcastAs() : get_class($this->event);
$broadcaster->broadcast(
Arr::wrap($this->event->broadcastOn()), $name,
$this->getPayloadFromEvent($this->event)
);
$channels = Arr::wrap($this->event->broadcastOn());
if (empty($channels)) {
return;
}
$connections = method_exists($this->event, 'broadcastConnections')
? $this->event->broadcastConnections()
: [null];
$payload = $this->getPayloadFromEvent($this->event);
foreach ($connections as $connection) {
$manager->connection($connection)->broadcast(
$channels, $name, $payload
);
}
}
/**
@@ -73,10 +87,9 @@ class BroadcastEvent implements ShouldQueue
*/
protected function getPayloadFromEvent($event)
{
if (method_exists($event, 'broadcastWith')) {
return array_merge(
$event->broadcastWith(), ['socket' => data_get($event, 'socket')]
);
if (method_exists($event, 'broadcastWith') &&
! is_null($payload = $event->broadcastWith())) {
return array_merge($payload, ['socket' => data_get($event, 'socket')]);
}
$payload = [];

View File

@@ -2,7 +2,9 @@
namespace Illuminate\Broadcasting;
use Ably\AblyRest;
use Closure;
use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster;
use Illuminate\Broadcasting\Broadcasters\LogBroadcaster;
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster;
@@ -70,7 +72,7 @@ class BroadcastManager implements FactoryContract
$router->match(
['get', 'post'], '/broadcasting/auth',
'\\'.BroadcastController::class.'@authenticate'
);
)->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
});
}
@@ -110,7 +112,10 @@ class BroadcastManager implements FactoryContract
*/
public function queue($event)
{
if ($event instanceof ShouldBroadcastNow) {
if ($event instanceof ShouldBroadcastNow ||
(is_object($event) &&
method_exists($event, 'shouldBroadcastNow') &&
$event->shouldBroadcastNow())) {
return $this->app->make(BusDispatcherContract::class)->dispatchNow(new BroadcastEvent(clone $event));
}
@@ -220,6 +225,17 @@ class BroadcastManager implements FactoryContract
return new PusherBroadcaster($pusher);
}
/**
* Create an instance of the driver.
*
* @param array $config
* @return \Illuminate\Contracts\Broadcasting\Broadcaster
*/
protected function createAblyDriver(array $config)
{
return new AblyBroadcaster(new AblyRest($config));
}
/**
* Create an instance of the driver.
*
@@ -294,6 +310,19 @@ class BroadcastManager implements FactoryContract
$this->app['config']['broadcasting.default'] = $name;
}
/**
* Disconnect the given disk and remove from local cache.
*
* @param string|null $name
* @return void
*/
public function purge($name = null)
{
$name = $name ?? $this->getDefaultDriver();
unset($this->drivers[$name]);
}
/**
* Register a custom driver creator Closure.
*
@@ -308,6 +337,41 @@ class BroadcastManager implements FactoryContract
return $this;
}
/**
* Get the application instance used by the manager.
*
* @return \Illuminate\Contracts\Foundation\Application
*/
public function getApplication()
{
return $this->app;
}
/**
* Set the application instance used by the manager.
*
* @param \Illuminate\Contracts\Foundation\Application $app
* @return $this
*/
public function setApplication($app)
{
$this->app = $app;
return $this;
}
/**
* Forget all of the resolved driver instances.
*
* @return $this
*/
public function forgetDrivers()
{
$this->drivers = [];
return $this;
}
/**
* Dynamically call the default driver instance.
*

View File

@@ -0,0 +1,225 @@
<?php
namespace Illuminate\Broadcasting\Broadcasters;
use Ably\AblyRest;
use Ably\Models\Message as AblyMessage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @author Matthew Hall (matthall28@gmail.com)
* @author Taylor Otwell (taylor@laravel.com)
*/
class AblyBroadcaster extends Broadcaster
{
/**
* The AblyRest SDK instance.
*
* @var \Ably\AblyRest
*/
protected $ably;
/**
* Create a new broadcaster instance.
*
* @param \Ably\AblyRest $ably
* @return void
*/
public function __construct(AblyRest $ably)
{
$this->ably = $ably;
}
/**
* Authenticate the incoming request for a given channel.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
public function auth($request)
{
$channelName = $this->normalizeChannelName($request->channel_name);
if (empty($request->channel_name) ||
($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName))) {
throw new AccessDeniedHttpException;
}
return parent::verifyUserCanAccessChannel(
$request, $channelName
);
}
/**
* Return the valid authentication response.
*
* @param \Illuminate\Http\Request $request
* @param mixed $result
* @return mixed
*/
public function validAuthenticationResponse($request, $result)
{
if (Str::startsWith($request->channel_name, 'private')) {
$signature = $this->generateAblySignature(
$request->channel_name, $request->socket_id
);
return ['auth' => $this->getPublicToken().':'.$signature];
}
$channelName = $this->normalizeChannelName($request->channel_name);
$user = $this->retrieveUser($request, $channelName);
$broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting')
? $user->getAuthIdentifierForBroadcasting()
: $user->getAuthIdentifier();
$signature = $this->generateAblySignature(
$request->channel_name,
$request->socket_id,
$userData = array_filter([
'user_id' => (string) $broadcastIdentifier,
'user_info' => $result,
])
);
return [
'auth' => $this->getPublicToken().':'.$signature,
'channel_data' => json_encode($userData),
];
}
/**
* Generate the signature needed for Ably authentication headers.
*
* @param string $channelName
* @param string $socketId
* @param array|null $userData
* @return string
*/
public function generateAblySignature($channelName, $socketId, $userData = null)
{
return hash_hmac(
'sha256',
sprintf('%s:%s%s', $socketId, $channelName, $userData ? ':'.json_encode($userData) : ''),
$this->getPrivateToken(),
);
}
/**
* Broadcast the given event.
*
* @param array $channels
* @param string $event
* @param array $payload
* @return void
*/
public function broadcast(array $channels, $event, array $payload = [])
{
foreach ($this->formatChannels($channels) as $channel) {
$this->ably->channels->get($channel)->publish(
$this->buildAblyMessage($event, $payload)
);
}
}
/**
* Build an Ably message object for broadcasting.
*
* @param string $event
* @param array $payload
* @return \Ably\Models\Message
*/
protected function buildAblyMessage($event, array $payload = [])
{
return tap(new AblyMessage, function ($message) use ($event, $payload) {
$message->name = $event;
$message->data = $payload;
$message->connectionKey = data_get($payload, 'socket');
});
}
/**
* Return true if the channel is protected by authentication.
*
* @param string $channel
* @return bool
*/
public function isGuardedChannel($channel)
{
return Str::startsWith($channel, ['private-', 'presence-']);
}
/**
* Remove prefix from channel name.
*
* @param string $channel
* @return string
*/
public function normalizeChannelName($channel)
{
if ($this->isGuardedChannel($channel)) {
return Str::startsWith($channel, 'private-')
? Str::replaceFirst('private-', '', $channel)
: Str::replaceFirst('presence-', '', $channel);
}
return $channel;
}
/**
* Format the channel array into an array of strings.
*
* @param array $channels
* @return array
*/
protected function formatChannels(array $channels)
{
return array_map(function ($channel) {
$channel = (string) $channel;
if (Str::startsWith($channel, ['private-', 'presence-'])) {
return Str::startsWith($channel, 'private-')
? Str::replaceFirst('private-', 'private:', $channel)
: Str::replaceFirst('presence-', 'presence:', $channel);
}
return 'public:'.$channel;
}, $channels);
}
/**
* Get the public token value from the Ably key.
*
* @return mixed
*/
protected function getPublicToken()
{
return Str::before($this->ably->options->key, ':');
}
/**
* Get the private token value from the Ably key.
*
* @return mixed
*/
protected function getPrivateToken()
{
return Str::after($this->ably->options->key, ':');
}
/**
* Get the underlying Ably SDK instance.
*
* @return \Ably\AblyRest
*/
public function getAbly()
{
return $this->ably;
}
}

View File

@@ -5,6 +5,7 @@ namespace Illuminate\Broadcasting\Broadcasters;
use Exception;
use Illuminate\Container\Container;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Contracts\Broadcasting\HasBroadcastChannel;
use Illuminate\Contracts\Routing\BindingRegistrar;
use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Support\Arr;
@@ -40,13 +41,19 @@ abstract class Broadcaster implements BroadcasterContract
/**
* Register a channel authenticator.
*
* @param string $channel
* @param \Illuminate\Contracts\Broadcasting\HasBroadcastChannel|string $channel
* @param callable|string $callback
* @param array $options
* @return $this
*/
public function channel($channel, $callback, $options = [])
{
if ($channel instanceof HasBroadcastChannel) {
$channel = $channel->broadcastChannelRoute();
} elseif (is_string($channel) && class_exists($channel) && is_a($channel, HasBroadcastChannel::class, true)) {
$channel = (new $channel)->broadcastChannelRoute();
}
$this->channels[$channel] = $callback;
$this->channelOptions[$channel] = $options;
@@ -317,7 +324,7 @@ abstract class Broadcaster implements BroadcasterContract
}
/**
* Check if channel name from request match a pattern from registered channels.
* Check if the channel name from the request matches a pattern from registered channels.
*
* @param string $channel
* @param string $pattern

View File

@@ -5,6 +5,7 @@ namespace Illuminate\Broadcasting\Broadcasters;
use Illuminate\Broadcasting\BroadcastException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Pusher\ApiErrorException;
use Pusher\Pusher;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -42,8 +43,9 @@ class PusherBroadcaster extends Broadcaster
{
$channelName = $this->normalizeChannelName($request->channel_name);
if ($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName)) {
if (empty($request->channel_name) ||
($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName))) {
throw new AccessDeniedHttpException;
}
@@ -69,11 +71,17 @@ class PusherBroadcaster extends Broadcaster
$channelName = $this->normalizeChannelName($request->channel_name);
$user = $this->retrieveUser($request, $channelName);
$broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting')
? $user->getAuthIdentifierForBroadcasting()
: $user->getAuthIdentifier();
return $this->decodePusherResponse(
$request,
$this->pusher->presence_auth(
$request->channel_name, $request->socket_id,
$this->retrieveUser($request, $channelName)->getAuthIdentifier(), $result
$broadcastIdentifier, $result
)
);
}
@@ -109,20 +117,44 @@ class PusherBroadcaster extends Broadcaster
{
$socket = Arr::pull($payload, 'socket');
$response = $this->pusher->trigger(
$this->formatChannels($channels), $event, $payload, $socket, true
);
if ($this->pusherServerIsVersionFiveOrGreater()) {
$parameters = $socket !== null ? ['socket_id' => $socket] : [];
if ((is_array($response) && $response['status'] >= 200 && $response['status'] <= 299)
|| $response === true) {
return;
try {
$this->pusher->trigger(
$this->formatChannels($channels), $event, $payload, $parameters
);
} catch (ApiErrorException $e) {
throw new BroadcastException(
sprintf('Pusher error: %s.', $e->getMessage())
);
}
} else {
$response = $this->pusher->trigger(
$this->formatChannels($channels), $event, $payload, $socket, true
);
if ((is_array($response) && $response['status'] >= 200 && $response['status'] <= 299)
|| $response === true) {
return;
}
throw new BroadcastException(
! empty($response['body'])
? sprintf('Pusher error: %s.', $response['body'])
: 'Failed to connect to Pusher.'
);
}
}
throw new BroadcastException(
! empty($response['body'])
? sprintf('Pusher error: %s.', $response['body'])
: 'Failed to connect to Pusher.'
);
/**
* Determine if the Pusher PHP server is version 5.0 or greater.
*
* @return bool
*/
protected function pusherServerIsVersionFiveOrGreater()
{
return class_exists(ApiErrorException::class);
}
/**
@@ -134,4 +166,15 @@ class PusherBroadcaster extends Broadcaster
{
return $this->pusher;
}
/**
* Set the Pusher SDK instance.
*
* @param \Pusher\Pusher $pusher
* @return void
*/
public function setPusher($pusher)
{
$this->pusher = $pusher;
}
}

View File

@@ -20,16 +20,16 @@ class RedisBroadcaster extends Broadcaster
/**
* The Redis connection to use for broadcasting.
*
* @var string
* @var ?string
*/
protected $connection;
protected $connection = null;
/**
* The Redis key prefix.
*
* @var string
*/
protected $prefix;
protected $prefix = '';
/**
* Create a new broadcaster instance.
@@ -60,8 +60,9 @@ class RedisBroadcaster extends Broadcaster
str_replace($this->prefix, '', $request->channel_name)
);
if ($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName)) {
if (empty($request->channel_name) ||
($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName))) {
throw new AccessDeniedHttpException;
}
@@ -85,8 +86,14 @@ class RedisBroadcaster extends Broadcaster
$channelName = $this->normalizeChannelName($request->channel_name);
$user = $this->retrieveUser($request, $channelName);
$broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting')
? $user->getAuthIdentifierForBroadcasting()
: $user->getAuthIdentifier();
return json_encode(['channel_data' => [
'user_id' => $this->retrieveUser($request, $channelName)->getAuthIdentifier(),
'user_id' => $broadcastIdentifier,
'user_info' => $result,
]]);
}

View File

@@ -7,7 +7,7 @@ use Illuminate\Support\Str;
trait UsePusherChannelConventions
{
/**
* Return true if channel is protected by authentication.
* Return true if the channel is protected by authentication.
*
* @param string $channel
* @return bool

View File

@@ -2,6 +2,8 @@
namespace Illuminate\Broadcasting;
use Illuminate\Contracts\Broadcasting\HasBroadcastChannel;
class Channel
{
/**
@@ -14,12 +16,12 @@ class Channel
/**
* Create a new channel instance.
*
* @param string $name
* @param \Illuminate\Contracts\Broadcasting\HasBroadcastChannel|string $name
* @return void
*/
public function __construct($name)
{
$this->name = $name;
$this->name = $name instanceof HasBroadcastChannel ? $name->broadcastChannel() : $name;
}
/**

View File

@@ -0,0 +1,40 @@
<?php
namespace Illuminate\Broadcasting;
use Illuminate\Support\Arr;
trait InteractsWithBroadcasting
{
/**
* The broadcaster connection to use to broadcast the event.
*
* @var array
*/
protected $broadcastConnection = [null];
/**
* Broadcast the event using a specific broadcaster.
*
* @param array|string|null $connection
* @return $this
*/
public function broadcastVia($connection = null)
{
$this->broadcastConnection = is_null($connection)
? [null]
: Arr::wrap($connection);
return $this;
}
/**
* Get the broadcaster connections the event should be broadcast on.
*
* @return array
*/
public function broadcastConnections()
{
return $this->broadcastConnection;
}
}

View File

@@ -33,6 +33,21 @@ class PendingBroadcast
$this->events = $events;
}
/**
* Broadcast the event using a specific broadcaster.
*
* @param string|null $connection
* @return $this
*/
public function via($connection = null)
{
if (method_exists($this->event, 'broadcastVia')) {
$this->event->broadcastVia($connection);
}
return $this;
}
/**
* Broadcast the event to everyone except the current user.
*

View File

@@ -2,16 +2,20 @@
namespace Illuminate\Broadcasting;
use Illuminate\Contracts\Broadcasting\HasBroadcastChannel;
class PrivateChannel extends Channel
{
/**
* Create a new channel instance.
*
* @param string $name
* @param \Illuminate\Contracts\Broadcasting\HasBroadcastChannel|string $name
* @return void
*/
public function __construct($name)
{
$name = $name instanceof HasBroadcastChannel ? $name->broadcastChannel() : $name;
parent::__construct('private-'.$name);
}
}

View File

@@ -14,13 +14,14 @@
}
],
"require": {
"php": "^7.2.5|^8.0",
"php": "^7.3|^8.0",
"ext-json": "*",
"psr/log": "^1.0",
"illuminate/bus": "^7.0",
"illuminate/contracts": "^7.0",
"illuminate/queue": "^7.0",
"illuminate/support": "^7.0"
"psr/log": "^1.0|^2.0",
"illuminate/bus": "^8.0",
"illuminate/collections": "^8.0",
"illuminate/contracts": "^8.0",
"illuminate/queue": "^8.0",
"illuminate/support": "^8.0"
},
"autoload": {
"psr-4": {
@@ -29,11 +30,12 @@
},
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
"dev-master": "8.x-dev"
}
},
"suggest": {
"pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0)."
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
"pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0|^5.0|^6.0|^7.0)."
},
"config": {
"sort-packages": true

View File

@@ -0,0 +1,481 @@
<?php
namespace Illuminate\Bus;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Contracts\Queue\Factory as QueueFactory;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Queue\CallQueuedClosure;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use JsonSerializable;
use Throwable;
class Batch implements Arrayable, JsonSerializable
{
/**
* The queue factory implementation.
*
* @var \Illuminate\Contracts\Queue\Factory
*/
protected $queue;
/**
* The repository implementation.
*
* @var \Illuminate\Bus\BatchRepository
*/
protected $repository;
/**
* The batch ID.
*
* @var string
*/
public $id;
/**
* The batch name.
*
* @var string
*/
public $name;
/**
* The total number of jobs that belong to the batch.
*
* @var int
*/
public $totalJobs;
/**
* The total number of jobs that are still pending.
*
* @var int
*/
public $pendingJobs;
/**
* The total number of jobs that have failed.
*
* @var int
*/
public $failedJobs;
/**
* The IDs of the jobs that have failed.
*
* @var array
*/
public $failedJobIds;
/**
* The batch options.
*
* @var array
*/
public $options;
/**
* The date indicating when the batch was created.
*
* @var \Carbon\CarbonImmutable
*/
public $createdAt;
/**
* The date indicating when the batch was cancelled.
*
* @var \Carbon\CarbonImmutable|null
*/
public $cancelledAt;
/**
* The date indicating when the batch was finished.
*
* @var \Carbon\CarbonImmutable|null
*/
public $finishedAt;
/**
* Create a new batch instance.
*
* @param \Illuminate\Contracts\Queue\Factory $queue
* @param \Illuminate\Bus\BatchRepository $repository
* @param string $id
* @param string $name
* @param int $totalJobs
* @param int $pendingJobs
* @param int $failedJobs
* @param array $failedJobIds
* @param array $options
* @param \Carbon\CarbonImmutable $createdAt
* @param \Carbon\CarbonImmutable|null $cancelledAt
* @param \Carbon\CarbonImmutable|null $finishedAt
* @return void
*/
public function __construct(QueueFactory $queue,
BatchRepository $repository,
string $id,
string $name,
int $totalJobs,
int $pendingJobs,
int $failedJobs,
array $failedJobIds,
array $options,
CarbonImmutable $createdAt,
?CarbonImmutable $cancelledAt = null,
?CarbonImmutable $finishedAt = null)
{
$this->queue = $queue;
$this->repository = $repository;
$this->id = $id;
$this->name = $name;
$this->totalJobs = $totalJobs;
$this->pendingJobs = $pendingJobs;
$this->failedJobs = $failedJobs;
$this->failedJobIds = $failedJobIds;
$this->options = $options;
$this->createdAt = $createdAt;
$this->cancelledAt = $cancelledAt;
$this->finishedAt = $finishedAt;
}
/**
* Get a fresh instance of the batch represented by this ID.
*
* @return self
*/
public function fresh()
{
return $this->repository->find($this->id);
}
/**
* Add additional jobs to the batch.
*
* @param \Illuminate\Support\Enumerable|array $jobs
* @return self
*/
public function add($jobs)
{
$count = 0;
$jobs = Collection::wrap($jobs)->map(function ($job) use (&$count) {
$job = $job instanceof Closure ? CallQueuedClosure::create($job) : $job;
if (is_array($job)) {
$count += count($job);
return with($this->prepareBatchedChain($job), function ($chain) {
return $chain->first()
->allOnQueue($this->options['queue'] ?? null)
->allOnConnection($this->options['connection'] ?? null)
->chain($chain->slice(1)->values()->all());
});
} else {
$job->withBatchId($this->id);
$count++;
}
return $job;
});
$this->repository->transaction(function () use ($jobs, $count) {
$this->repository->incrementTotalJobs($this->id, $count);
$this->queue->connection($this->options['connection'] ?? null)->bulk(
$jobs->all(),
$data = '',
$this->options['queue'] ?? null
);
});
return $this->fresh();
}
/**
* Prepare a chain that exists within the jobs being added.
*
* @param array $chain
* @return \Illuminate\Support\Collection
*/
protected function prepareBatchedChain(array $chain)
{
return collect($chain)->map(function ($job) {
$job = $job instanceof Closure ? CallQueuedClosure::create($job) : $job;
return $job->withBatchId($this->id);
});
}
/**
* Get the total number of jobs that have been processed by the batch thus far.
*
* @return int
*/
public function processedJobs()
{
return $this->totalJobs - $this->pendingJobs;
}
/**
* Get the percentage of jobs that have been processed (between 0-100).
*
* @return int
*/
public function progress()
{
return $this->totalJobs > 0 ? round(($this->processedJobs() / $this->totalJobs) * 100) : 0;
}
/**
* Record that a job within the batch finished successfully, executing any callbacks if necessary.
*
* @param string $jobId
* @return void
*/
public function recordSuccessfulJob(string $jobId)
{
$counts = $this->decrementPendingJobs($jobId);
if ($counts->pendingJobs === 0) {
$this->repository->markAsFinished($this->id);
}
if ($counts->pendingJobs === 0 && $this->hasThenCallbacks()) {
$batch = $this->fresh();
collect($this->options['then'])->each(function ($handler) use ($batch) {
$this->invokeHandlerCallback($handler, $batch);
});
}
if ($counts->allJobsHaveRanExactlyOnce() && $this->hasFinallyCallbacks()) {
$batch = $this->fresh();
collect($this->options['finally'])->each(function ($handler) use ($batch) {
$this->invokeHandlerCallback($handler, $batch);
});
}
}
/**
* Decrement the pending jobs for the batch.
*
* @param string $jobId
* @return \Illuminate\Bus\UpdatedBatchJobCounts
*/
public function decrementPendingJobs(string $jobId)
{
return $this->repository->decrementPendingJobs($this->id, $jobId);
}
/**
* Determine if the batch has finished executing.
*
* @return bool
*/
public function finished()
{
return ! is_null($this->finishedAt);
}
/**
* Determine if the batch has "success" callbacks.
*
* @return bool
*/
public function hasThenCallbacks()
{
return isset($this->options['then']) && ! empty($this->options['then']);
}
/**
* Determine if the batch allows jobs to fail without cancelling the batch.
*
* @return bool
*/
public function allowsFailures()
{
return Arr::get($this->options, 'allowFailures', false) === true;
}
/**
* Determine if the batch has job failures.
*
* @return bool
*/
public function hasFailures()
{
return $this->failedJobs > 0;
}
/**
* Record that a job within the batch failed to finish successfully, executing any callbacks if necessary.
*
* @param string $jobId
* @param \Throwable $e
* @return void
*/
public function recordFailedJob(string $jobId, $e)
{
$counts = $this->incrementFailedJobs($jobId);
if ($counts->failedJobs === 1 && ! $this->allowsFailures()) {
$this->cancel();
}
if ($counts->failedJobs === 1 && $this->hasCatchCallbacks()) {
$batch = $this->fresh();
collect($this->options['catch'])->each(function ($handler) use ($batch, $e) {
$this->invokeHandlerCallback($handler, $batch, $e);
});
}
if ($counts->allJobsHaveRanExactlyOnce() && $this->hasFinallyCallbacks()) {
$batch = $this->fresh();
collect($this->options['finally'])->each(function ($handler) use ($batch, $e) {
$this->invokeHandlerCallback($handler, $batch, $e);
});
}
}
/**
* Increment the failed jobs for the batch.
*
* @param string $jobId
* @return \Illuminate\Bus\UpdatedBatchJobCounts
*/
public function incrementFailedJobs(string $jobId)
{
return $this->repository->incrementFailedJobs($this->id, $jobId);
}
/**
* Determine if the batch has "catch" callbacks.
*
* @return bool
*/
public function hasCatchCallbacks()
{
return isset($this->options['catch']) && ! empty($this->options['catch']);
}
/**
* Determine if the batch has "finally" callbacks.
*
* @return bool
*/
public function hasFinallyCallbacks()
{
return isset($this->options['finally']) && ! empty($this->options['finally']);
}
/**
* Cancel the batch.
*
* @return void
*/
public function cancel()
{
$this->repository->cancel($this->id);
}
/**
* Determine if the batch has been cancelled.
*
* @return bool
*/
public function canceled()
{
return $this->cancelled();
}
/**
* Determine if the batch has been cancelled.
*
* @return bool
*/
public function cancelled()
{
return ! is_null($this->cancelledAt);
}
/**
* Delete the batch from storage.
*
* @return void
*/
public function delete()
{
$this->repository->delete($this->id);
}
/**
* Invoke a batch callback handler.
*
* @param callable $handler
* @param \Illuminate\Bus\Batch $batch
* @param \Throwable|null $e
* @return void
*/
protected function invokeHandlerCallback($handler, Batch $batch, Throwable $e = null)
{
try {
return $handler($batch, $e);
} catch (Throwable $e) {
if (function_exists('report')) {
report($e);
}
}
}
/**
* Convert the batch to an array.
*
* @return array
*/
public function toArray()
{
return [
'id' => $this->id,
'name' => $this->name,
'totalJobs' => $this->totalJobs,
'pendingJobs' => $this->pendingJobs,
'processedJobs' => $this->processedJobs(),
'progress' => $this->progress(),
'failedJobs' => $this->failedJobs,
'options' => $this->options,
'createdAt' => $this->createdAt,
'cancelledAt' => $this->cancelledAt,
'finishedAt' => $this->finishedAt,
];
}
/**
* Get the JSON serializable representation of the object.
*
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->toArray();
}
/**
* Dynamically access the batch's "options" via properties.
*
* @param string $key
* @return mixed
*/
public function __get($key)
{
return $this->options[$key] ?? null;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Illuminate\Bus;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Queue\Factory as QueueFactory;
class BatchFactory
{
/**
* The queue factory implementation.
*
* @var \Illuminate\Contracts\Queue\Factory
*/
protected $queue;
/**
* Create a new batch factory instance.
*
* @param \Illuminate\Contracts\Queue\Factory $queue
* @return void
*/
public function __construct(QueueFactory $queue)
{
$this->queue = $queue;
}
/**
* Create a new batch instance.
*
* @param \Illuminate\Bus\BatchRepository $repository
* @param string $id
* @param string $name
* @param int $totalJobs
* @param int $pendingJobs
* @param int $failedJobs
* @param array $failedJobIds
* @param array $options
* @param \Carbon\CarbonImmutable $createdAt
* @param \Carbon\CarbonImmutable|null $cancelledAt
* @param \Carbon\CarbonImmutable|null $finishedAt
* @return \Illuminate\Bus\Batch
*/
public function make(BatchRepository $repository,
string $id,
string $name,
int $totalJobs,
int $pendingJobs,
int $failedJobs,
array $failedJobIds,
array $options,
CarbonImmutable $createdAt,
?CarbonImmutable $cancelledAt,
?CarbonImmutable $finishedAt)
{
return new Batch($this->queue, $repository, $id, $name, $totalJobs, $pendingJobs, $failedJobs, $failedJobIds, $options, $createdAt, $cancelledAt, $finishedAt);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Illuminate\Bus;
use Closure;
interface BatchRepository
{
/**
* Retrieve a list of batches.
*
* @param int $limit
* @param mixed $before
* @return \Illuminate\Bus\Batch[]
*/
public function get($limit, $before);
/**
* Retrieve information about an existing batch.
*
* @param string $batchId
* @return \Illuminate\Bus\Batch|null
*/
public function find(string $batchId);
/**
* Store a new pending batch.
*
* @param \Illuminate\Bus\PendingBatch $batch
* @return \Illuminate\Bus\Batch
*/
public function store(PendingBatch $batch);
/**
* Increment the total number of jobs within the batch.
*
* @param string $batchId
* @param int $amount
* @return void
*/
public function incrementTotalJobs(string $batchId, int $amount);
/**
* Decrement the total number of pending jobs for the batch.
*
* @param string $batchId
* @param string $jobId
* @return \Illuminate\Bus\UpdatedBatchJobCounts
*/
public function decrementPendingJobs(string $batchId, string $jobId);
/**
* Increment the total number of failed jobs for the batch.
*
* @param string $batchId
* @param string $jobId
* @return \Illuminate\Bus\UpdatedBatchJobCounts
*/
public function incrementFailedJobs(string $batchId, string $jobId);
/**
* Mark the batch that has the given ID as finished.
*
* @param string $batchId
* @return void
*/
public function markAsFinished(string $batchId);
/**
* Cancel the batch that has the given ID.
*
* @param string $batchId
* @return void
*/
public function cancel(string $batchId);
/**
* Delete the batch that has the given ID.
*
* @param string $batchId
* @return void
*/
public function delete(string $batchId);
/**
* Execute the given Closure within a storage specific transaction.
*
* @param \Closure $callback
* @return mixed
*/
public function transaction(Closure $callback);
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Illuminate\Bus;
use Illuminate\Container\Container;
trait Batchable
{
/**
* The batch ID (if applicable).
*
* @var string
*/
public $batchId;
/**
* Get the batch instance for the job, if applicable.
*
* @return \Illuminate\Bus\Batch|null
*/
public function batch()
{
if ($this->batchId) {
return Container::getInstance()->make(BatchRepository::class)->find($this->batchId);
}
}
/**
* Determine if the batch is still active and processing.
*
* @return bool
*/
public function batching()
{
$batch = $this->batch();
return $batch && ! $batch->cancelled();
}
/**
* Set the batch ID on the job.
*
* @param string $batchId
* @return $this
*/
public function withBatchId(string $batchId)
{
$this->batchId = $batchId;
return $this;
}
}

View File

@@ -23,6 +23,8 @@ class BusServiceProvider extends ServiceProvider implements DeferrableProvider
});
});
$this->registerBatchServices();
$this->app->alias(
Dispatcher::class, DispatcherContract::class
);
@@ -32,6 +34,24 @@ class BusServiceProvider extends ServiceProvider implements DeferrableProvider
);
}
/**
* Register the batch handling services.
*
* @return void
*/
protected function registerBatchServices()
{
$this->app->singleton(BatchRepository::class, DatabaseBatchRepository::class);
$this->app->singleton(DatabaseBatchRepository::class, function ($app) {
return new DatabaseBatchRepository(
$app->make(BatchFactory::class),
$app->make('db')->connection($app->config->get('queue.batching.database')),
$app->config->get('queue.batching.table', 'job_batches')
);
});
}
/**
* Get the services provided by the provider.
*
@@ -43,6 +63,7 @@ class BusServiceProvider extends ServiceProvider implements DeferrableProvider
Dispatcher::class,
DispatcherContract::class,
QueueingDispatcherContract::class,
BatchRepository::class,
];
}
}

View File

@@ -0,0 +1,347 @@
<?php
namespace Illuminate\Bus;
use Carbon\CarbonImmutable;
use Closure;
use DateTimeInterface;
use Illuminate\Database\Connection;
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
class DatabaseBatchRepository implements PrunableBatchRepository
{
/**
* The batch factory instance.
*
* @var \Illuminate\Bus\BatchFactory
*/
protected $factory;
/**
* The database connection instance.
*
* @var \Illuminate\Database\Connection
*/
protected $connection;
/**
* The database table to use to store batch information.
*
* @var string
*/
protected $table;
/**
* Create a new batch repository instance.
*
* @param \Illuminate\Bus\BatchFactory $factory
* @param \Illuminate\Database\Connection $connection
* @param string $table
*/
public function __construct(BatchFactory $factory, Connection $connection, string $table)
{
$this->factory = $factory;
$this->connection = $connection;
$this->table = $table;
}
/**
* Retrieve a list of batches.
*
* @param int $limit
* @param mixed $before
* @return \Illuminate\Bus\Batch[]
*/
public function get($limit = 50, $before = null)
{
return $this->connection->table($this->table)
->orderByDesc('id')
->take($limit)
->when($before, function ($q) use ($before) {
return $q->where('id', '<', $before);
})
->get()
->map(function ($batch) {
return $this->toBatch($batch);
})
->all();
}
/**
* Retrieve information about an existing batch.
*
* @param string $batchId
* @return \Illuminate\Bus\Batch|null
*/
public function find(string $batchId)
{
$batch = $this->connection->table($this->table)
->where('id', $batchId)
->first();
if ($batch) {
return $this->toBatch($batch);
}
}
/**
* Store a new pending batch.
*
* @param \Illuminate\Bus\PendingBatch $batch
* @return \Illuminate\Bus\Batch
*/
public function store(PendingBatch $batch)
{
$id = (string) Str::orderedUuid();
$this->connection->table($this->table)->insert([
'id' => $id,
'name' => $batch->name,
'total_jobs' => 0,
'pending_jobs' => 0,
'failed_jobs' => 0,
'failed_job_ids' => '[]',
'options' => $this->serialize($batch->options),
'created_at' => time(),
'cancelled_at' => null,
'finished_at' => null,
]);
return $this->find($id);
}
/**
* Increment the total number of jobs within the batch.
*
* @param string $batchId
* @param int $amount
* @return void
*/
public function incrementTotalJobs(string $batchId, int $amount)
{
$this->connection->table($this->table)->where('id', $batchId)->update([
'total_jobs' => new Expression('total_jobs + '.$amount),
'pending_jobs' => new Expression('pending_jobs + '.$amount),
'finished_at' => null,
]);
}
/**
* Decrement the total number of pending jobs for the batch.
*
* @param string $batchId
* @param string $jobId
* @return \Illuminate\Bus\UpdatedBatchJobCounts
*/
public function decrementPendingJobs(string $batchId, string $jobId)
{
$values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) {
return [
'pending_jobs' => $batch->pending_jobs - 1,
'failed_jobs' => $batch->failed_jobs,
'failed_job_ids' => json_encode(array_values(array_diff(json_decode($batch->failed_job_ids, true), [$jobId]))),
];
});
return new UpdatedBatchJobCounts(
$values['pending_jobs'],
$values['failed_jobs']
);
}
/**
* Increment the total number of failed jobs for the batch.
*
* @param string $batchId
* @param string $jobId
* @return \Illuminate\Bus\UpdatedBatchJobCounts
*/
public function incrementFailedJobs(string $batchId, string $jobId)
{
$values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) {
return [
'pending_jobs' => $batch->pending_jobs,
'failed_jobs' => $batch->failed_jobs + 1,
'failed_job_ids' => json_encode(array_values(array_unique(array_merge(json_decode($batch->failed_job_ids, true), [$jobId])))),
];
});
return new UpdatedBatchJobCounts(
$values['pending_jobs'],
$values['failed_jobs']
);
}
/**
* Update an atomic value within the batch.
*
* @param string $batchId
* @param \Closure $callback
* @return int|null
*/
protected function updateAtomicValues(string $batchId, Closure $callback)
{
return $this->connection->transaction(function () use ($batchId, $callback) {
$batch = $this->connection->table($this->table)->where('id', $batchId)
->lockForUpdate()
->first();
return is_null($batch) ? [] : tap($callback($batch), function ($values) use ($batchId) {
$this->connection->table($this->table)->where('id', $batchId)->update($values);
});
});
}
/**
* Mark the batch that has the given ID as finished.
*
* @param string $batchId
* @return void
*/
public function markAsFinished(string $batchId)
{
$this->connection->table($this->table)->where('id', $batchId)->update([
'finished_at' => time(),
]);
}
/**
* Cancel the batch that has the given ID.
*
* @param string $batchId
* @return void
*/
public function cancel(string $batchId)
{
$this->connection->table($this->table)->where('id', $batchId)->update([
'cancelled_at' => time(),
'finished_at' => time(),
]);
}
/**
* Delete the batch that has the given ID.
*
* @param string $batchId
* @return void
*/
public function delete(string $batchId)
{
$this->connection->table($this->table)->where('id', $batchId)->delete();
}
/**
* Prune all of the entries older than the given date.
*
* @param \DateTimeInterface $before
* @return int
*/
public function prune(DateTimeInterface $before)
{
$query = $this->connection->table($this->table)
->whereNotNull('finished_at')
->where('finished_at', '<', $before->getTimestamp());
$totalDeleted = 0;
do {
$deleted = $query->take(1000)->delete();
$totalDeleted += $deleted;
} while ($deleted !== 0);
return $totalDeleted;
}
/**
* Prune all of the unfinished entries older than the given date.
*
* @param \DateTimeInterface $before
* @return int
*/
public function pruneUnfinished(DateTimeInterface $before)
{
$query = $this->connection->table($this->table)
->whereNull('finished_at')
->where('created_at', '<', $before->getTimestamp());
$totalDeleted = 0;
do {
$deleted = $query->take(1000)->delete();
$totalDeleted += $deleted;
} while ($deleted !== 0);
return $totalDeleted;
}
/**
* Execute the given Closure within a storage specific transaction.
*
* @param \Closure $callback
* @return mixed
*/
public function transaction(Closure $callback)
{
return $this->connection->transaction(function () use ($callback) {
return $callback();
});
}
/**
* Serialize the given value.
*
* @param mixed $value
* @return string
*/
protected function serialize($value)
{
$serialized = serialize($value);
return $this->connection instanceof PostgresConnection
? base64_encode($serialized)
: $serialized;
}
/**
* Unserialize the given value.
*
* @param string $serialized
* @return mixed
*/
protected function unserialize($serialized)
{
if ($this->connection instanceof PostgresConnection &&
! Str::contains($serialized, [':', ';'])) {
$serialized = base64_decode($serialized);
}
return unserialize($serialized);
}
/**
* Convert the given raw batch to a Batch object.
*
* @param object $batch
* @return \Illuminate\Bus\Batch
*/
protected function toBatch($batch)
{
return $this->factory->make(
$this,
$batch->id,
$batch->name,
(int) $batch->total_jobs,
(int) $batch->pending_jobs,
(int) $batch->failed_jobs,
json_decode($batch->failed_job_ids, true),
$this->unserialize($batch->options),
CarbonImmutable::createFromTimestamp($batch->created_at),
$batch->cancelled_at ? CarbonImmutable::createFromTimestamp($batch->cancelled_at) : $batch->cancelled_at,
$batch->finished_at ? CarbonImmutable::createFromTimestamp($batch->finished_at) : $batch->finished_at
);
}
}

View File

@@ -7,7 +7,11 @@ use Illuminate\Contracts\Bus\QueueingDispatcher;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Queue\Queue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\PendingChain;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Jobs\SyncJob;
use Illuminate\Support\Collection;
use RuntimeException;
class Dispatcher implements QueueingDispatcher
@@ -69,35 +73,100 @@ class Dispatcher implements QueueingDispatcher
*/
public function dispatch($command)
{
if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
return $this->dispatchToQueue($command);
}
return $this->dispatchNow($command);
return $this->queueResolver && $this->commandShouldBeQueued($command)
? $this->dispatchToQueue($command)
: $this->dispatchNow($command);
}
/**
* Dispatch a command to its appropriate handler in the current process.
*
* Queueable jobs will be dispatched to the "sync" queue.
*
* @param mixed $command
* @param mixed $handler
* @return mixed
*/
public function dispatchSync($command, $handler = null)
{
if ($this->queueResolver &&
$this->commandShouldBeQueued($command) &&
method_exists($command, 'onConnection')) {
return $this->dispatchToQueue($command->onConnection('sync'));
}
return $this->dispatchNow($command, $handler);
}
/**
* Dispatch a command to its appropriate handler in the current process without using the synchronous queue.
*
* @param mixed $command
* @param mixed $handler
* @return mixed
*/
public function dispatchNow($command, $handler = null)
{
$uses = class_uses_recursive($command);
if (in_array(InteractsWithQueue::class, $uses) &&
in_array(Queueable::class, $uses) &&
! $command->job) {
$command->setJob(new SyncJob($this->container, json_encode([]), 'sync', 'sync'));
}
if ($handler || $handler = $this->getCommandHandler($command)) {
$callback = function ($command) use ($handler) {
return $handler->handle($command);
$method = method_exists($handler, 'handle') ? 'handle' : '__invoke';
return $handler->{$method}($command);
};
} else {
$callback = function ($command) {
return $this->container->call([$command, 'handle']);
$method = method_exists($command, 'handle') ? 'handle' : '__invoke';
return $this->container->call([$command, $method]);
};
}
return $this->pipeline->send($command)->through($this->pipes)->then($callback);
}
/**
* Attempt to find the batch with the given ID.
*
* @param string $batchId
* @return \Illuminate\Bus\Batch|null
*/
public function findBatch(string $batchId)
{
return $this->container->make(BatchRepository::class)->find($batchId);
}
/**
* Create a new batch of queueable jobs.
*
* @param \Illuminate\Support\Collection|array|mixed $jobs
* @return \Illuminate\Bus\PendingBatch
*/
public function batch($jobs)
{
return new PendingBatch($this->container, Collection::wrap($jobs));
}
/**
* Create a new chain of queueable jobs.
*
* @param \Illuminate\Support\Collection|array $jobs
* @return \Illuminate\Foundation\Bus\PendingChain
*/
public function chain($jobs)
{
$jobs = Collection::wrap($jobs);
return new PendingChain($jobs->shift(), $jobs->toArray());
}
/**
* Determine if the given command has a handler.
*
@@ -140,6 +209,8 @@ class Dispatcher implements QueueingDispatcher
*
* @param mixed $command
* @return mixed
*
* @throws \RuntimeException
*/
public function dispatchToQueue($command)
{

View File

@@ -0,0 +1,26 @@
<?php
namespace Illuminate\Bus\Events;
use Illuminate\Bus\Batch;
class BatchDispatched
{
/**
* The batch instance.
*
* @var \Illuminate\Bus\Batch
*/
public $batch;
/**
* Create a new event instance.
*
* @param \Illuminate\Bus\Batch $batch
* @return void
*/
public function __construct(Batch $batch)
{
$this->batch = $batch;
}
}

View File

@@ -0,0 +1,272 @@
<?php
namespace Illuminate\Bus;
use Closure;
use Illuminate\Bus\Events\BatchDispatched;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Queue\SerializableClosureFactory;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Throwable;
class PendingBatch
{
/**
* The IoC container instance.
*
* @var \Illuminate\Contracts\Container\Container
*/
protected $container;
/**
* The batch name.
*
* @var string
*/
public $name = '';
/**
* The jobs that belong to the batch.
*
* @var \Illuminate\Support\Collection
*/
public $jobs;
/**
* The batch options.
*
* @var array
*/
public $options = [];
/**
* Create a new pending batch instance.
*
* @param \Illuminate\Contracts\Container\Container $container
* @param \Illuminate\Support\Collection $jobs
* @return void
*/
public function __construct(Container $container, Collection $jobs)
{
$this->container = $container;
$this->jobs = $jobs;
}
/**
* Add jobs to the batch.
*
* @param iterable $jobs
* @return $this
*/
public function add($jobs)
{
foreach ($jobs as $job) {
$this->jobs->push($job);
}
return $this;
}
/**
* Add a callback to be executed after all jobs in the batch have executed successfully.
*
* @param callable $callback
* @return $this
*/
public function then($callback)
{
$this->options['then'][] = $callback instanceof Closure
? SerializableClosureFactory::make($callback)
: $callback;
return $this;
}
/**
* Get the "then" callbacks that have been registered with the pending batch.
*
* @return array
*/
public function thenCallbacks()
{
return $this->options['then'] ?? [];
}
/**
* Add a callback to be executed after the first failing job in the batch.
*
* @param callable $callback
* @return $this
*/
public function catch($callback)
{
$this->options['catch'][] = $callback instanceof Closure
? SerializableClosureFactory::make($callback)
: $callback;
return $this;
}
/**
* Get the "catch" callbacks that have been registered with the pending batch.
*
* @return array
*/
public function catchCallbacks()
{
return $this->options['catch'] ?? [];
}
/**
* Add a callback to be executed after the batch has finished executing.
*
* @param callable $callback
* @return $this
*/
public function finally($callback)
{
$this->options['finally'][] = $callback instanceof Closure
? SerializableClosureFactory::make($callback)
: $callback;
return $this;
}
/**
* Get the "finally" callbacks that have been registered with the pending batch.
*
* @return array
*/
public function finallyCallbacks()
{
return $this->options['finally'] ?? [];
}
/**
* Indicate that the batch should not be cancelled when a job within the batch fails.
*
* @param bool $allowFailures
* @return $this
*/
public function allowFailures($allowFailures = true)
{
$this->options['allowFailures'] = $allowFailures;
return $this;
}
/**
* Determine if the pending batch allows jobs to fail without cancelling the batch.
*
* @return bool
*/
public function allowsFailures()
{
return Arr::get($this->options, 'allowFailures', false) === true;
}
/**
* Set the name for the batch.
*
* @param string $name
* @return $this
*/
public function name(string $name)
{
$this->name = $name;
return $this;
}
/**
* Specify the queue connection that the batched jobs should run on.
*
* @param string $connection
* @return $this
*/
public function onConnection(string $connection)
{
$this->options['connection'] = $connection;
return $this;
}
/**
* Get the connection used by the pending batch.
*
* @return string|null
*/
public function connection()
{
return $this->options['connection'] ?? null;
}
/**
* Specify the queue that the batched jobs should run on.
*
* @param string $queue
* @return $this
*/
public function onQueue(string $queue)
{
$this->options['queue'] = $queue;
return $this;
}
/**
* Get the queue used by the pending batch.
*
* @return string|null
*/
public function queue()
{
return $this->options['queue'] ?? null;
}
/**
* Add additional data into the batch's options array.
*
* @param string $key
* @param mixed $value
* @return $this
*/
public function withOption(string $key, $value)
{
$this->options[$key] = $value;
return $this;
}
/**
* Dispatch the batch.
*
* @return \Illuminate\Bus\Batch
*
* @throws \Throwable
*/
public function dispatch()
{
$repository = $this->container->make(BatchRepository::class);
try {
$batch = $repository->store($this);
$batch = $batch->add($this->jobs);
} catch (Throwable $e) {
if (isset($batch)) {
$repository->delete($batch->id);
}
throw $e;
}
$this->container->make(EventDispatcher::class)->dispatch(
new BatchDispatched($batch)
);
return $batch;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Illuminate\Bus;
use DateTimeInterface;
interface PrunableBatchRepository extends BatchRepository
{
/**
* Prune all of the entries older than the given date.
*
* @param \DateTimeInterface $before
* @return int
*/
public function prune(DateTimeInterface $before);
}

View File

@@ -37,6 +37,13 @@ trait Queueable
*/
public $chainQueue;
/**
* The callbacks to be executed on chain failure.
*
* @var array|null
*/
public $chainCatchCallbacks;
/**
* The number of seconds before the job should be made available.
*
@@ -44,6 +51,13 @@ trait Queueable
*/
public $delay;
/**
* Indicates whether the job should be dispatched after all database transactions have committed.
*
* @var bool|null
*/
public $afterCommit;
/**
* The middleware the job should be dispatched through.
*
@@ -125,6 +139,30 @@ trait Queueable
return $this;
}
/**
* Indicate that the job should be dispatched after all database transactions have committed.
*
* @return $this
*/
public function afterCommit()
{
$this->afterCommit = true;
return $this;
}
/**
* Indicate that the job should not wait until database transactions have been committed before dispatching.
*
* @return $this
*/
public function beforeCommit()
{
$this->afterCommit = false;
return $this;
}
/**
* Specify the middleware the job should be dispatched through.
*
@@ -158,6 +196,8 @@ trait Queueable
*
* @param mixed $job
* @return string
*
* @throws \RuntimeException
*/
protected function serializeJob($job)
{
@@ -190,7 +230,21 @@ trait Queueable
$next->chainConnection = $this->chainConnection;
$next->chainQueue = $this->chainQueue;
$next->chainCatchCallbacks = $this->chainCatchCallbacks;
}));
}
}
/**
* Invoke all of the chain's failed job callbacks.
*
* @param \Throwable $e
* @return void
*/
public function invokeChainCatchCallbacks($e)
{
collect($this->chainCatchCallbacks)->each(function ($callback) use ($e) {
$callback($e);
});
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Illuminate\Bus;
use Illuminate\Contracts\Cache\Repository as Cache;
class UniqueLock
{
/**
* The cache repository implementation.
*
* @var \Illuminate\Contracts\Cache\Repository
*/
protected $cache;
/**
* Create a new unique lock manager instance.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
* @return void
*/
public function __construct(Cache $cache)
{
$this->cache = $cache;
}
/**
* Attempt to acquire a lock for the given job.
*
* @param mixed $job
* @return bool
*/
public function acquire($job)
{
$uniqueId = method_exists($job, 'uniqueId')
? $job->uniqueId()
: ($job->uniqueId ?? '');
$cache = method_exists($job, 'uniqueVia')
? $job->uniqueVia()
: $this->cache;
return (bool) $cache->lock(
$key = 'laravel_unique_job:'.get_class($job).$uniqueId,
$job->uniqueFor ?? 0
)->get();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Illuminate\Bus;
class UpdatedBatchJobCounts
{
/**
* The number of pending jobs remaining for the batch.
*
* @var int
*/
public $pendingJobs;
/**
* The number of failed jobs that belong to the batch.
*
* @var int
*/
public $failedJobs;
/**
* Create a new batch job counts object.
*
* @param int $pendingJobs
* @param int $failedJobs
* @return void
*/
public function __construct(int $pendingJobs = 0, int $failedJobs = 0)
{
$this->pendingJobs = $pendingJobs;
$this->failedJobs = $failedJobs;
}
/**
* Determine if all jobs have run exactly once.
*
* @return bool
*/
public function allJobsHaveRanExactlyOnce()
{
return ($this->pendingJobs - $this->failedJobs) === 0;
}
}

View File

@@ -14,10 +14,11 @@
}
],
"require": {
"php": "^7.2.5|^8.0",
"illuminate/contracts": "^7.0",
"illuminate/pipeline": "^7.0",
"illuminate/support": "^7.0"
"php": "^7.3|^8.0",
"illuminate/collections": "^8.0",
"illuminate/contracts": "^8.0",
"illuminate/pipeline": "^8.0",
"illuminate/support": "^8.0"
},
"autoload": {
"psr-4": {
@@ -26,7 +27,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
"dev-master": "8.x-dev"
}
},
"suggest": {

View File

@@ -0,0 +1,85 @@
<?php
namespace Illuminate\Cache;
class CacheLock extends Lock
{
/**
* The cache store implementation.
*
* @var \Illuminate\Contracts\Cache\Store
*/
protected $store;
/**
* Create a new lock instance.
*
* @param \Illuminate\Contracts\Cache\Store $store
* @param string $name
* @param int $seconds
* @param string|null $owner
* @return void
*/
public function __construct($store, $name, $seconds, $owner = null)
{
parent::__construct($name, $seconds, $owner);
$this->store = $store;
}
/**
* Attempt to acquire the lock.
*
* @return bool
*/
public function acquire()
{
if (method_exists($this->store, 'add') && $this->seconds > 0) {
return $this->store->add(
$this->name, $this->owner, $this->seconds
);
}
if (! is_null($this->store->get($this->name))) {
return false;
}
return ($this->seconds > 0)
? $this->store->put($this->name, $this->owner, $this->seconds)
: $this->store->forever($this->name, $this->owner, $this->seconds);
}
/**
* Release the lock.
*
* @return bool
*/
public function release()
{
if ($this->isOwnedByCurrentProcess()) {
return $this->store->forget($this->name);
}
return false;
}
/**
* Releases this lock regardless of ownership.
*
* @return void
*/
public function forceRelease()
{
$this->store->forget($this->name);
}
/**
* Returns the owner value written into the driver for this lock.
*
* @return mixed
*/
protected function getCurrentOwner()
{
return $this->store->get($this->name);
}
}

View File

@@ -199,7 +199,11 @@ class CacheManager implements FactoryContract
$connection = $config['connection'] ?? 'default';
return $this->repository(new RedisStore($redis, $this->getPrefix($config), $connection));
$store = new RedisStore($redis, $this->getPrefix($config), $connection);
return $this->repository(
$store->setLockConnection($config['lock_connection'] ?? $connection)
);
}
/**
@@ -212,15 +216,17 @@ class CacheManager implements FactoryContract
{
$connection = $this->app['db']->connection($config['connection'] ?? null);
return $this->repository(
new DatabaseStore(
$connection,
$config['table'],
$this->getPrefix($config),
$config['lock_table'] ?? 'cache_locks',
$config['lock_lottery'] ?? [2, 100]
)
$store = new DatabaseStore(
$connection,
$config['table'],
$this->getPrefix($config),
$config['lock_table'] ?? 'cache_locks',
$config['lock_lottery'] ?? [2, 100]
);
return $this->repository($store->setLockConnection(
$this->app['db']->connection($config['lock_connection'] ?? $config['connection'] ?? null)
));
}
/**
@@ -231,21 +237,11 @@ class CacheManager implements FactoryContract
*/
protected function createDynamodbDriver(array $config)
{
$dynamoConfig = [
'region' => $config['region'],
'version' => 'latest',
'endpoint' => $config['endpoint'] ?? null,
];
if ($config['key'] && $config['secret']) {
$dynamoConfig['credentials'] = Arr::only(
$config, ['key', 'secret', 'token']
);
}
$client = $this->newDynamodbClient($config);
return $this->repository(
new DynamoDbStore(
new DynamoDbClient($dynamoConfig),
$client,
$config['table'],
$config['attributes']['key'] ?? 'key',
$config['attributes']['value'] ?? 'value',
@@ -255,6 +251,28 @@ class CacheManager implements FactoryContract
);
}
/**
* Create new DynamoDb Client instance.
*
* @return DynamoDbClient
*/
protected function newDynamodbClient(array $config)
{
$dynamoConfig = [
'region' => $config['region'],
'version' => 'latest',
'endpoint' => $config['endpoint'] ?? null,
];
if (isset($config['key']) && isset($config['secret'])) {
$dynamoConfig['credentials'] = Arr::only(
$config, ['key', 'secret', 'token']
);
}
return new DynamoDbClient($dynamoConfig);
}
/**
* Create a new cache repository with the given implementation.
*
@@ -314,7 +332,11 @@ class CacheManager implements FactoryContract
*/
protected function getConfig($name)
{
return $this->app['config']["cache.stores.{$name}"];
if (! is_null($name) && $name !== 'null') {
return $this->app['config']["cache.stores.{$name}"];
}
return ['driver' => 'null'];
}
/**
@@ -357,6 +379,19 @@ class CacheManager implements FactoryContract
return $this;
}
/**
* Disconnect the given driver and remove from local cache.
*
* @param string|null $name
* @return void
*/
public function purge($name = null)
{
$name = $name ?? $this->getDefaultDriver();
unset($this->stores[$name]);
}
/**
* Register a custom driver creator Closure.
*

View File

@@ -30,6 +30,12 @@ class CacheServiceProvider extends ServiceProvider implements DeferrableProvider
$this->app->singleton('memcached.connector', function () {
return new MemcachedConnector;
});
$this->app->singleton(RateLimiter::class, function ($app) {
return new RateLimiter($app->make('cache')->driver(
$app['config']->get('cache.limiter')
));
});
}
/**
@@ -40,7 +46,7 @@ class CacheServiceProvider extends ServiceProvider implements DeferrableProvider
public function provides()
{
return [
'cache', 'cache.store', 'cache.psr6', 'memcached.connector',
'cache', 'cache.store', 'cache.psr6', 'memcached.connector', RateLimiter::class,
];
}
}

View File

@@ -116,7 +116,7 @@ class ClearCommand extends Command
*/
protected function tags()
{
return array_filter(explode(',', $this->option('tags')));
return array_filter(explode(',', $this->option('tags') ?? ''));
}
/**

View File

@@ -14,10 +14,16 @@ class CreateCacheTable extends Migration
public function up()
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->unique();
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
@@ -28,5 +34,6 @@ class CreateCacheTable extends Migration
public function down()
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
}

View File

@@ -136,4 +136,14 @@ class DatabaseLock extends Lock
{
return optional($this->connection->table($this->table)->where('key', $this->name)->first())->owner;
}
/**
* Get the name of the database connection being used to manage the lock.
*
* @return string
*/
public function getConnectionName()
{
return $this->connection->getName();
}
}

View File

@@ -23,6 +23,13 @@ class DatabaseStore implements LockProvider, Store
*/
protected $connection;
/**
* The database connection instance that should be used to manage locks.
*
* @var \Illuminate\Database\ConnectionInterface
*/
protected $lockConnection;
/**
* The name of the cache table.
*
@@ -155,8 +162,6 @@ class DatabaseStore implements LockProvider, Store
'expiration' => $expiration,
]) >= 1;
}
return false;
}
/**
@@ -267,7 +272,7 @@ class DatabaseStore implements LockProvider, Store
public function lock($name, $seconds = 0, $owner = null)
{
return new DatabaseLock(
$this->connection,
$this->lockConnection ?? $this->connection,
$this->lockTable,
$this->prefix.$name,
$seconds,
@@ -333,6 +338,19 @@ class DatabaseStore implements LockProvider, Store
return $this->connection;
}
/**
* Specify the name of the connection that should be used to manage locks.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @return $this
*/
public function setLockConnection($connection)
{
$this->lockConnection = $connection;
return $this;
}
/**
* Get the cache key prefix.
*

View File

@@ -34,9 +34,11 @@ class DynamoDbLock extends Lock
*/
public function acquire()
{
return $this->dynamo->add(
$this->name, $this->owner, $this->seconds
);
if ($this->seconds > 0) {
return $this->dynamo->add($this->name, $this->owner, $this->seconds);
} else {
return $this->dynamo->add($this->name, $this->owner, 86400);
}
}
/**

View File

@@ -525,4 +525,14 @@ class DynamoDbStore implements LockProvider, Store
{
$this->prefix = ! empty($prefix) ? $prefix.':' : '';
}
/**
* Get the DynamoDb Client instance.
*
* @return DynamoDbClient
*/
public function getClient()
{
return $this->dynamo;
}
}

View File

@@ -3,13 +3,16 @@
namespace Illuminate\Cache;
use Exception;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Cache\Store;
use Illuminate\Contracts\Filesystem\LockTimeoutException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Filesystem\LockableFile;
use Illuminate\Support\InteractsWithTime;
class FileStore implements Store
class FileStore implements Store, LockProvider
{
use InteractsWithTime, RetrievesMultipleKeys;
use InteractsWithTime, HasCacheLock, RetrievesMultipleKeys;
/**
* The Illuminate Filesystem instance.
@@ -75,7 +78,7 @@ class FileStore implements Store
);
if ($result !== false && $result > 0) {
$this->ensureFileHasCorrectPermissions($path);
$this->ensurePermissionsAreCorrect($path);
return true;
}
@@ -83,6 +86,45 @@ class FileStore implements Store
return false;
}
/**
* Store an item in the cache if the key doesn't exist.
*
* @param string $key
* @param mixed $value
* @param int $seconds
* @return bool
*/
public function add($key, $value, $seconds)
{
$this->ensureCacheDirectoryExists($path = $this->path($key));
$file = new LockableFile($path, 'c+');
try {
$file->getExclusiveLock();
} catch (LockTimeoutException $e) {
$file->close();
return false;
}
$expire = $file->read(10);
if (empty($expire) || $this->currentTime() >= $expire) {
$file->truncate()
->write($this->expiration($seconds).serialize($value))
->close();
$this->ensurePermissionsAreCorrect($path);
return true;
}
$file->close();
return false;
}
/**
* Create the file cache directory if necessary.
*
@@ -91,18 +133,24 @@ class FileStore implements Store
*/
protected function ensureCacheDirectoryExists($path)
{
if (! $this->files->exists(dirname($path))) {
$this->files->makeDirectory(dirname($path), 0777, true, true);
$directory = dirname($path);
if (! $this->files->exists($directory)) {
$this->files->makeDirectory($directory, 0777, true, true);
// We're creating two levels of directories (e.g. 7e/24), so we check them both...
$this->ensurePermissionsAreCorrect($directory);
$this->ensurePermissionsAreCorrect(dirname($directory));
}
}
/**
* Ensure the cache file has the correct permissions.
* Ensure the created node has the correct permissions.
*
* @param string $path
* @return void
*/
protected function ensureFileHasCorrectPermissions($path)
protected function ensurePermissionsAreCorrect($path)
{
if (is_null($this->filePermission) ||
intval($this->files->chmod($path), 8) == $this->filePermission) {

View File

@@ -0,0 +1,31 @@
<?php
namespace Illuminate\Cache;
trait HasCacheLock
{
/**
* Get a lock instance.
*
* @param string $name
* @param int $seconds
* @param string|null $owner
* @return \Illuminate\Contracts\Cache\Lock
*/
public function lock($name, $seconds = 0, $owner = null)
{
return new CacheLock($this, $name, $seconds, $owner);
}
/**
* Restore a lock instance using the owner identifier.
*
* @param string $name
* @param string $owner
* @return \Illuminate\Contracts\Cache\Lock
*/
public function restoreLock($name, $owner)
{
return $this->lock($name, 0, $owner);
}
}

View File

@@ -105,7 +105,7 @@ abstract class Lock implements LockContract
*
* @param int $seconds
* @param callable|null $callback
* @return bool
* @return mixed
*
* @throws \Illuminate\Contracts\Cache\LockTimeoutException
*/
@@ -153,7 +153,7 @@ abstract class Lock implements LockContract
}
/**
* Specify the number of milliseconds to sleep in between blocked lock aquisition attempts.
* Specify the number of milliseconds to sleep in between blocked lock acquisition attempts.
*
* @param int $milliseconds
* @return $this

View File

@@ -0,0 +1,46 @@
<?php
namespace Illuminate\Cache;
class NoLock extends Lock
{
/**
* Attempt to acquire the lock.
*
* @return bool
*/
public function acquire()
{
return true;
}
/**
* Release the lock.
*
* @return bool
*/
public function release()
{
return true;
}
/**
* Releases this lock in disregard of ownership.
*
* @return void
*/
public function forceRelease()
{
//
}
/**
* Returns the owner value written into the driver for this lock.
*
* @return mixed
*/
protected function getCurrentOwner()
{
return $this->owner;
}
}

View File

@@ -2,7 +2,9 @@
namespace Illuminate\Cache;
class NullStore extends TaggableStore
use Illuminate\Contracts\Cache\LockProvider;
class NullStore extends TaggableStore implements LockProvider
{
use RetrievesMultipleKeys;
@@ -10,7 +12,7 @@ class NullStore extends TaggableStore
* Retrieve an item from the cache by key.
*
* @param string $key
* @return mixed
* @return void
*/
public function get($key)
{
@@ -35,7 +37,7 @@ class NullStore extends TaggableStore
*
* @param string $key
* @param mixed $value
* @return int|bool
* @return bool
*/
public function increment($key, $value = 1)
{
@@ -47,7 +49,7 @@ class NullStore extends TaggableStore
*
* @param string $key
* @param mixed $value
* @return int|bool
* @return bool
*/
public function decrement($key, $value = 1)
{
@@ -66,6 +68,31 @@ class NullStore extends TaggableStore
return false;
}
/**
* Get a lock instance.
*
* @param string $name
* @param int $seconds
* @param string|null $owner
* @return \Illuminate\Contracts\Cache\Lock
*/
public function lock($name, $seconds = 0, $owner = null)
{
return new NoLock($name, $seconds, $owner);
}
/**
* Restore a lock instance using the owner identifier.
*
* @param string $name
* @param string $owner
* @return \Illuminate\Contracts\Cache\Lock
*/
public function restoreLock($name, $owner)
{
return $this->lock($name, 0, $owner);
}
/**
* Remove an item from the cache.
*

View File

@@ -0,0 +1,35 @@
<?php
namespace Illuminate\Cache;
use Illuminate\Redis\Connections\PhpRedisConnection;
class PhpRedisLock extends RedisLock
{
/**
* Create a new phpredis lock instance.
*
* @param \Illuminate\Redis\Connections\PhpRedisConnection $redis
* @param string $name
* @param int $seconds
* @param string|null $owner
* @return void
*/
public function __construct(PhpRedisConnection $redis, string $name, int $seconds, ?string $owner = null)
{
parent::__construct($redis, $name, $seconds, $owner);
}
/**
* {@inheritDoc}
*/
public function release()
{
return (bool) $this->redis->eval(
LuaScripts::releaseLock(),
1,
$this->name,
...$this->redis->pack([$this->owner])
);
}
}

View File

@@ -2,6 +2,7 @@
namespace Illuminate\Cache;
use Closure;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\InteractsWithTime;
@@ -16,6 +17,13 @@ class RateLimiter
*/
protected $cache;
/**
* The configured limit object resolvers.
*
* @var array
*/
protected $limiters = [];
/**
* Create a new rate limiter instance.
*
@@ -27,6 +35,51 @@ class RateLimiter
$this->cache = $cache;
}
/**
* Register a named limiter configuration.
*
* @param string $name
* @param \Closure $callback
* @return $this
*/
public function for(string $name, Closure $callback)
{
$this->limiters[$name] = $callback;
return $this;
}
/**
* Get the given named rate limiter.
*
* @param string $name
* @return \Closure
*/
public function limiter(string $name)
{
return $this->limiters[$name] ?? null;
}
/**
* Attempts to execute a callback if it's not limited.
*
* @param string $key
* @param int $maxAttempts
* @param \Closure $callback
* @param int $decaySeconds
* @return mixed
*/
public function attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)
{
if ($this->tooManyAttempts($key, $maxAttempts)) {
return false;
}
return tap($callback() ?: true, function () use ($key, $decaySeconds) {
$this->hit($key, $decaySeconds);
});
}
/**
* Determine if the given key has been "accessed" too many times.
*
@@ -36,6 +89,8 @@ class RateLimiter
*/
public function tooManyAttempts($key, $maxAttempts)
{
$key = $this->cleanRateLimiterKey($key);
if ($this->attempts($key) >= $maxAttempts) {
if ($this->cache->has($key.':timer')) {
return true;
@@ -56,6 +111,8 @@ class RateLimiter
*/
public function hit($key, $decaySeconds = 60)
{
$key = $this->cleanRateLimiterKey($key);
$this->cache->add(
$key.':timer', $this->availableAt($decaySeconds), $decaySeconds
);
@@ -79,6 +136,8 @@ class RateLimiter
*/
public function attempts($key)
{
$key = $this->cleanRateLimiterKey($key);
return $this->cache->get($key, 0);
}
@@ -90,9 +149,27 @@ class RateLimiter
*/
public function resetAttempts($key)
{
$key = $this->cleanRateLimiterKey($key);
return $this->cache->forget($key);
}
/**
* Get the number of retries left for the given key.
*
* @param string $key
* @param int $maxAttempts
* @return int
*/
public function remaining($key, $maxAttempts)
{
$key = $this->cleanRateLimiterKey($key);
$attempts = $this->attempts($key);
return $maxAttempts - $attempts;
}
/**
* Get the number of retries left for the given key.
*
@@ -102,9 +179,7 @@ class RateLimiter
*/
public function retriesLeft($key, $maxAttempts)
{
$attempts = $this->attempts($key);
return $maxAttempts - $attempts;
return $this->remaining($key, $maxAttempts);
}
/**
@@ -115,6 +190,8 @@ class RateLimiter
*/
public function clear($key)
{
$key = $this->cleanRateLimiterKey($key);
$this->resetAttempts($key);
$this->cache->forget($key.':timer');
@@ -128,6 +205,19 @@ class RateLimiter
*/
public function availableIn($key)
{
return $this->cache->get($key.':timer') - $this->currentTime();
$key = $this->cleanRateLimiterKey($key);
return max(0, $this->cache->get($key.':timer') - $this->currentTime());
}
/**
* Clean the rate limiter key from unicode characters.
*
* @param string $key
* @return string
*/
public function cleanRateLimiterKey($key)
{
return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Illuminate\Cache\RateLimiting;
class GlobalLimit extends Limit
{
/**
* Create a new limit instance.
*
* @param int $maxAttempts
* @param int $decayMinutes
* @return void
*/
public function __construct(int $maxAttempts, int $decayMinutes = 1)
{
parent::__construct('', $maxAttempts, $decayMinutes);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Illuminate\Cache\RateLimiting;
class Limit
{
/**
* The rate limit signature key.
*
* @var mixed|string
*/
public $key;
/**
* The maximum number of attempts allowed within the given number of minutes.
*
* @var int
*/
public $maxAttempts;
/**
* The number of minutes until the rate limit is reset.
*
* @var int
*/
public $decayMinutes;
/**
* The response generator callback.
*
* @var callable
*/
public $responseCallback;
/**
* Create a new limit instance.
*
* @param mixed|string $key
* @param int $maxAttempts
* @param int $decayMinutes
* @return void
*/
public function __construct($key = '', int $maxAttempts = 60, int $decayMinutes = 1)
{
$this->key = $key;
$this->maxAttempts = $maxAttempts;
$this->decayMinutes = $decayMinutes;
}
/**
* Create a new rate limit.
*
* @param int $maxAttempts
* @return static
*/
public static function perMinute($maxAttempts)
{
return new static('', $maxAttempts);
}
/**
* Create a new rate limit using minutes as decay time.
*
* @param int $decayMinutes
* @param int $maxAttempts
* @return static
*/
public static function perMinutes($decayMinutes, $maxAttempts)
{
return new static('', $maxAttempts, $decayMinutes);
}
/**
* Create a new rate limit using hours as decay time.
*
* @param int $maxAttempts
* @param int $decayHours
* @return static
*/
public static function perHour($maxAttempts, $decayHours = 1)
{
return new static('', $maxAttempts, 60 * $decayHours);
}
/**
* Create a new rate limit using days as decay time.
*
* @param int $maxAttempts
* @param int $decayDays
* @return static
*/
public static function perDay($maxAttempts, $decayDays = 1)
{
return new static('', $maxAttempts, 60 * 24 * $decayDays);
}
/**
* Create a new unlimited rate limit.
*
* @return static
*/
public static function none()
{
return new Unlimited;
}
/**
* Set the key of the rate limit.
*
* @param string $key
* @return $this
*/
public function by($key)
{
$this->key = $key;
return $this;
}
/**
* Set the callback that should generate the response when the limit is exceeded.
*
* @param callable $callback
* @return $this
*/
public function response(callable $callback)
{
$this->responseCallback = $callback;
return $this;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Illuminate\Cache\RateLimiting;
class Unlimited extends GlobalLimit
{
/**
* Create a new limit instance.
*
* @return void
*/
public function __construct()
{
parent::__construct(PHP_INT_MAX);
}
}

View File

@@ -70,4 +70,14 @@ class RedisLock extends Lock
{
return $this->redis->get($this->name);
}
/**
* Get the name of the Redis connection being used to manage the lock.
*
* @return string
*/
public function getConnectionName()
{
return $this->redis->getName();
}
}

View File

@@ -4,6 +4,7 @@ namespace Illuminate\Cache;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Redis\Factory as Redis;
use Illuminate\Redis\Connections\PhpRedisConnection;
class RedisStore extends TaggableStore implements LockProvider
{
@@ -22,12 +23,19 @@ class RedisStore extends TaggableStore implements LockProvider
protected $prefix;
/**
* The Redis connection that should be used.
* The Redis connection instance that should be used to manage locks.
*
* @var string
*/
protected $connection;
/**
* The name of the connection that should be used for locks.
*
* @var string
*/
protected $lockConnection;
/**
* Create a new Redis store.
*
@@ -181,7 +189,15 @@ class RedisStore extends TaggableStore implements LockProvider
*/
public function lock($name, $seconds = 0, $owner = null)
{
return new RedisLock($this->connection(), $this->prefix.$name, $seconds, $owner);
$lockName = $this->prefix.$name;
$lockConnection = $this->lockConnection();
if ($lockConnection instanceof PhpRedisConnection) {
return new PhpRedisLock($lockConnection, $lockName, $seconds, $owner);
}
return new RedisLock($lockConnection, $lockName, $seconds, $owner);
}
/**
@@ -243,7 +259,17 @@ class RedisStore extends TaggableStore implements LockProvider
}
/**
* Set the connection name to be used.
* Get the Redis connection instance that should be used to manage locks.
*
* @return \Illuminate\Redis\Connections\Connection
*/
public function lockConnection()
{
return $this->redis->connection($this->lockConnection ?? $this->connection);
}
/**
* Specify the name of the connection that should be used to store data.
*
* @param string $connection
* @return void
@@ -253,6 +279,19 @@ class RedisStore extends TaggableStore implements LockProvider
$this->connection = $connection;
}
/**
* Specify the name of the connection that should be used to manage locks.
*
* @param string $connection
* @return $this
*/
public function setLockConnection($connection)
{
$this->lockConnection = $connection;
return $this;
}
/**
* Get the Redis database instance.
*

View File

@@ -10,6 +10,7 @@ class RedisTaggedCache extends TaggedCache
* @var string
*/
const REFERENCE_KEY_FOREVER = 'forever_ref';
/**
* Standard reference key.
*
@@ -41,13 +42,13 @@ class RedisTaggedCache extends TaggedCache
*
* @param string $key
* @param mixed $value
* @return void
* @return int|bool
*/
public function increment($key, $value = 1)
{
$this->pushStandardKeys($this->tags->getNamespace(), $key);
parent::increment($key, $value);
return parent::increment($key, $value);
}
/**
@@ -55,13 +56,13 @@ class RedisTaggedCache extends TaggedCache
*
* @param string $key
* @param mixed $value
* @return void
* @return int|bool
*/
public function decrement($key, $value = 1)
{
$this->pushStandardKeys($this->tags->getNamespace(), $key);
parent::decrement($key, $value);
return parent::decrement($key, $value);
}
/**
@@ -88,7 +89,9 @@ class RedisTaggedCache extends TaggedCache
$this->deleteForeverKeys();
$this->deleteStandardKeys();
return parent::flush();
$this->tags->flush();
return true;
}
/**
@@ -175,13 +178,26 @@ class RedisTaggedCache extends TaggedCache
*/
protected function deleteValues($referenceKey)
{
$values = array_unique($this->store->connection()->smembers($referenceKey));
$cursor = $defaultCursorValue = '0';
if (count($values) > 0) {
foreach (array_chunk($values, 1000) as $valuesChunk) {
do {
[$cursor, $valuesChunk] = $this->store->connection()->sscan(
$referenceKey, $cursor, ['match' => '*', 'count' => 1000]
);
// PhpRedis client returns false if set does not exist or empty. Array destruction
// on false stores null in each variable. If valuesChunk is null, it means that
// there were not results from the previously executed "sscan" Redis command.
if (is_null($valuesChunk)) {
break;
}
$valuesChunk = array_unique($valuesChunk);
if (count($valuesChunk) > 0) {
$this->store->connection()->del(...$valuesChunk);
}
}
} while (((string) $cursor) !== $defaultCursorValue);
}
/**

View File

@@ -131,6 +131,8 @@ class Repository implements ArrayAccess, CacheContract
/**
* {@inheritdoc}
*
* @return iterable
*/
public function getMultiple($keys, $default = null)
{
@@ -219,6 +221,8 @@ class Repository implements ArrayAccess, CacheContract
/**
* {@inheritdoc}
*
* @return bool
*/
public function set($key, $value, $ttl = null)
{
@@ -276,6 +280,8 @@ class Repository implements ArrayAccess, CacheContract
/**
* {@inheritdoc}
*
* @return bool
*/
public function setMultiple($values, $ttl = null)
{
@@ -292,8 +298,12 @@ class Repository implements ArrayAccess, CacheContract
*/
public function add($key, $value, $ttl = null)
{
$seconds = null;
if ($ttl !== null) {
if ($this->getSeconds($ttl) <= 0) {
$seconds = $this->getSeconds($ttl);
if ($seconds <= 0) {
return false;
}
@@ -301,8 +311,6 @@ class Repository implements ArrayAccess, CacheContract
// has a chance to override this logic. Some drivers better support the way
// this operation should work with a total "atomic" implementation of it.
if (method_exists($this->store, 'add')) {
$seconds = $this->getSeconds($ttl);
return $this->store->add(
$this->itemKey($key), $value, $seconds
);
@@ -313,7 +321,7 @@ class Repository implements ArrayAccess, CacheContract
// so it exists for subsequent requests. Then, we will return true so it is
// easy to know if the value gets added. Otherwise, we will return false.
if (is_null($this->get($key))) {
return $this->put($key, $value, $ttl);
return $this->put($key, $value, $seconds);
}
return false;
@@ -365,7 +373,7 @@ class Repository implements ArrayAccess, CacheContract
* Get an item from the cache, or execute the given Closure and store the result.
*
* @param string $key
* @param \DateTimeInterface|\DateInterval|int|null $ttl
* @param \Closure|\DateTimeInterface|\DateInterval|int|null $ttl
* @param \Closure $callback
* @return mixed
*/
@@ -380,7 +388,7 @@ class Repository implements ArrayAccess, CacheContract
return $value;
}
$this->put($key, $value = $callback(), $ttl);
$this->put($key, $value = $callback(), value($ttl));
return $value;
}
@@ -437,6 +445,8 @@ class Repository implements ArrayAccess, CacheContract
/**
* {@inheritdoc}
*
* @return bool
*/
public function delete($key)
{
@@ -445,6 +455,8 @@ class Repository implements ArrayAccess, CacheContract
/**
* {@inheritdoc}
*
* @return bool
*/
public function deleteMultiple($keys)
{
@@ -461,6 +473,8 @@ class Repository implements ArrayAccess, CacheContract
/**
* {@inheritdoc}
*
* @return bool
*/
public function clear()
{
@@ -477,7 +491,7 @@ class Repository implements ArrayAccess, CacheContract
*/
public function tags($names)
{
if (! method_exists($this->store, 'tags')) {
if (! $this->supportsTags()) {
throw new BadMethodCallException('This cache store does not support tagging.');
}
@@ -501,6 +515,33 @@ class Repository implements ArrayAccess, CacheContract
return $key;
}
/**
* Calculate the number of seconds for the given TTL.
*
* @param \DateTimeInterface|\DateInterval|int $ttl
* @return int
*/
protected function getSeconds($ttl)
{
$duration = $this->parseDateInterval($ttl);
if ($duration instanceof DateTimeInterface) {
$duration = Carbon::now()->diffInRealSeconds($duration, false);
}
return (int) ($duration > 0 ? $duration : 0);
}
/**
* Determine if the current store supports tags.
*
* @return bool
*/
public function supportsTags()
{
return method_exists($this->store, 'tags');
}
/**
* Get the default cache time.
*
@@ -537,7 +578,7 @@ class Repository implements ArrayAccess, CacheContract
/**
* Fire an event for this cache instance.
*
* @param string $event
* @param object|string $event
* @return void
*/
protected function event($event)
@@ -574,6 +615,7 @@ class Repository implements ArrayAccess, CacheContract
* @param string $key
* @return bool
*/
#[\ReturnTypeWillChange]
public function offsetExists($key)
{
return $this->has($key);
@@ -585,6 +627,7 @@ class Repository implements ArrayAccess, CacheContract
* @param string $key
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($key)
{
return $this->get($key);
@@ -597,6 +640,7 @@ class Repository implements ArrayAccess, CacheContract
* @param mixed $value
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetSet($key, $value)
{
$this->put($key, $value, $this->default);
@@ -608,28 +652,12 @@ class Repository implements ArrayAccess, CacheContract
* @param string $key
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetUnset($key)
{
$this->forget($key);
}
/**
* Calculate the number of seconds for the given TTL.
*
* @param \DateTimeInterface|\DateInterval|int $ttl
* @return int
*/
protected function getSeconds($ttl)
{
$duration = $this->parseDateInterval($ttl);
if ($duration instanceof DateTimeInterface) {
$duration = Carbon::now()->diffInRealSeconds($duration, false);
}
return (int) $duration > 0 ? $duration : 0;
}
/**
* Handle dynamic calls into macros or pass missing methods to the store.
*

View File

@@ -16,8 +16,12 @@ trait RetrievesMultipleKeys
{
$return = [];
foreach ($keys as $key) {
$return[$key] = $this->get($key);
$keys = collect($keys)->mapWithKeys(function ($value, $key) {
return [is_string($key) ? $key : $value => is_string($key) ? $value : null];
})->all();
foreach ($keys as $key => $default) {
$return[$key] = $this->get($key, $default);
}
return $return;

View File

@@ -56,6 +56,26 @@ class TagSet
return $id;
}
/**
* Flush all the tags in the set.
*
* @return void
*/
public function flush()
{
array_walk($this->names, [$this, 'flushTag']);
}
/**
* Flush the tag from the cache.
*
* @param string $name
*/
public function flushTag($name)
{
$this->store->forget($this->tagKey($name));
}
/**
* Get a unique namespace that changes when any of the tags are flushed.
*

View File

@@ -52,11 +52,11 @@ class TaggedCache extends Repository
*
* @param string $key
* @param mixed $value
* @return void
* @return int|bool
*/
public function increment($key, $value = 1)
{
$this->store->increment($this->itemKey($key), $value);
return $this->store->increment($this->itemKey($key), $value);
}
/**
@@ -64,11 +64,11 @@ class TaggedCache extends Repository
*
* @param string $key
* @param mixed $value
* @return void
* @return int|bool
*/
public function decrement($key, $value = 1)
{
$this->store->decrement($this->itemKey($key), $value);
return $this->store->decrement($this->itemKey($key), $value);
}
/**
@@ -105,7 +105,7 @@ class TaggedCache extends Repository
/**
* Fire an event for this cache instance.
*
* @param string $event
* @param \Illuminate\Cache\Events\CacheEvent $event
* @return void
*/
protected function event($event)

View File

@@ -14,9 +14,14 @@
}
],
"require": {
"php": "^7.2.5|^8.0",
"illuminate/contracts": "^7.0",
"illuminate/support": "^7.0"
"php": "^7.3|^8.0",
"illuminate/collections": "^8.0",
"illuminate/contracts": "^8.0",
"illuminate/macroable": "^8.0",
"illuminate/support": "^8.0"
},
"provide": {
"psr/simple-cache-implementation": "1.0"
},
"autoload": {
"psr-4": {
@@ -25,15 +30,15 @@
},
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
"dev-master": "8.x-dev"
}
},
"suggest": {
"ext-memcached": "Required to use the memcache cache driver.",
"illuminate/database": "Required to use the database cache driver (^7.0).",
"illuminate/filesystem": "Required to use the file cache driver (^7.0).",
"illuminate/redis": "Required to use the redis cache driver (^7.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^5.0)."
"illuminate/database": "Required to use the database cache driver (^8.0).",
"illuminate/filesystem": "Required to use the file cache driver (^8.0).",
"illuminate/redis": "Required to use the redis cache driver (^8.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^5.4)."
},
"config": {
"sort-packages": true

Some files were not shown because too many files have changed in this diff Show More