From 054e5b6a86a69c42042b427056cda039db757951 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Mon, 27 Apr 2020 10:05:42 +0200 Subject: [PATCH] wip --- app/Client/Client.php | 5 + app/Client/Connections/ControlConnection.php | 1 + app/HttpServer/Controllers/Controller.php | 3 +- app/Providers/AppServiceProvider.php | 8 +- app/Server/Factory.php | 55 +-- .../Controllers/Admin/ListSitesController.php | 8 +- .../Controllers/Admin/LoginController.php | 25 ++ .../Admin/VerifyLoginController.php | 51 +++ .../Controllers/ControlMessageController.php | 24 ++ .../Controllers/TunnelMessageController.php | 1 + app/Server/Http/RouteGenerator.php | 78 +++++ app/Server/Http/Router.php | 126 +++++++ composer.json | 5 +- composer.lock | 104 +++++- database/migrations/01_create_users_table.sql | 1 + resources/views/server/layouts/app.twig | 67 ++++ resources/views/server/login.twig | 47 +++ resources/views/server/sites/index.twig | 272 +++++---------- resources/views/server/users/index.twig | 315 +++++------------- tests/Unit/ExampleTest.php | 2 +- 20 files changed, 737 insertions(+), 461 deletions(-) create mode 100644 app/Server/Http/Controllers/Admin/LoginController.php create mode 100644 app/Server/Http/Controllers/Admin/VerifyLoginController.php create mode 100644 app/Server/Http/RouteGenerator.php create mode 100644 app/Server/Http/Router.php create mode 100644 resources/views/server/layouts/app.twig create mode 100644 resources/views/server/login.twig diff --git a/app/Client/Client.php b/app/Client/Client.php index a3b385f..69479b4 100644 --- a/app/Client/Client.php +++ b/app/Client/Client.php @@ -61,6 +61,11 @@ class Client exit(1); }); + $connection->on('subdomainTaken', function ($data) { + $this->logger->error("The chosen subdomain \"{$data->data->subdomain}\" is already taken. Please choose a different subdomain."); + exit(1); + }); + $connection->on('authenticated', function ($data) { $this->logger->info("Connected to http://$data->subdomain.{$this->configuration->host()}:{$this->configuration->port()}"); diff --git a/app/Client/Connections/ControlConnection.php b/app/Client/Connections/ControlConnection.php index 9adf715..05c8f50 100644 --- a/app/Client/Connections/ControlConnection.php +++ b/app/Client/Connections/ControlConnection.php @@ -35,6 +35,7 @@ class ControlConnection $decodedEntry = json_decode($message); $this->emit($decodedEntry->event ?? '', [$decodedEntry]); + if (method_exists($this, $decodedEntry->event ?? '')) { call_user_func([$this, $decodedEntry->event], $decodedEntry); } diff --git a/app/HttpServer/Controllers/Controller.php b/app/HttpServer/Controllers/Controller.php index de8de21..39f30f8 100644 --- a/app/HttpServer/Controllers/Controller.php +++ b/app/HttpServer/Controllers/Controller.php @@ -26,12 +26,13 @@ abstract class Controller implements HttpServerInterface { } - protected function getView(string $view, array $data) + protected function getView(string $view, array $data = []) { $templatePath = implode(DIRECTORY_SEPARATOR, explode('.', $view)); $twig = new Environment( new ArrayLoader([ + 'app' => file_get_contents(base_path('resources/views/server/layouts/app.twig')), 'template' => file_get_contents(base_path('resources/views/'.$templatePath.'.twig')), ]) ); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 28278f1..ef0c8f5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,10 +31,13 @@ class AppServiceProvider extends ServiceProvider protected function loadConfigurationFile() { + $builtInConfig = config('expose'); + $localConfigFile = getcwd() . DIRECTORY_SEPARATOR . '.expose.php'; if (file_exists($localConfigFile)) { - config()->set('expose', require_once $localConfigFile); + $localConfig = require_once $localConfigFile; + config()->set('expose', array_merge($builtInConfig, $localConfig)); return; } @@ -46,7 +49,8 @@ class AppServiceProvider extends ServiceProvider ]); if (file_exists($configFile)) { - config()->set('expose', require_once $configFile); + $globalConfig = require_once $configFile; + config()->set('expose', array_merge($builtInConfig, $globalConfig)); } } } diff --git a/app/Server/Factory.php b/app/Server/Factory.php index 0c14a5f..4456a0b 100644 --- a/app/Server/Factory.php +++ b/app/Server/Factory.php @@ -9,12 +9,15 @@ use App\Server\Connections\ConnectionManager; use App\Server\Http\Controllers\Admin\DeleteUsersController; use App\Server\Http\Controllers\Admin\ListSitesController; use App\Server\Http\Controllers\Admin\ListUsersController; +use App\Server\Http\Controllers\Admin\LoginController; use App\Server\Http\Controllers\Admin\StoreUsersController; +use App\Server\Http\Controllers\Admin\VerifyLoginController; use App\Server\Http\Controllers\ControlMessageController; use App\Server\Http\Controllers\TunnelMessageController; +use App\Server\Http\RouteGenerator; +use App\Server\Http\Router; use App\Server\SubdomainGenerator\RandomSubdomainGenerator; use Clue\React\SQLite\DatabaseInterface; -use Ratchet\Http\Router; use Ratchet\Server\IoServer; use Ratchet\WebSocket\WsServer; use React\Socket\Server; @@ -22,6 +25,7 @@ use React\EventLoop\LoopInterface; use React\EventLoop\Factory as LoopFactory; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler; use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\Route; @@ -41,13 +45,13 @@ class Factory /** @var \React\EventLoop\LoopInterface */ protected $loop; - /** @var RouteCollection */ - protected $routes; + /** @var RouteGenerator */ + protected $router; public function __construct() { $this->loop = LoopFactory::create(); - $this->routes = new RouteCollection(); + $this->router = new RouteGenerator(); } public function setHost(string $host) @@ -80,17 +84,9 @@ class Factory protected function addExposeRoutes() { - $wsServer = new WsServer(app(ControlMessageController::class)); - $wsServer->enableKeepAlive($this->loop); + $this->router->get('/__expose_control__', ControlMessageController::class); - $this->routes->add('control', - new Route('/__expose_control__', [ - '_controller' => $wsServer - ], [], [], null, [], [] - ) - ); - - $this->routes->add('tunnel', + $this->router->addSymfonyRoute('tunnel', new Route('/{__catchall__}', [ '_controller' => app(TunnelMessageController::class), ], [ @@ -100,29 +96,14 @@ class Factory protected function addAdminRoutes() { - $this->routes->add('admin.users.index', - new Route('/expose/users', [ - '_controller' => app(ListUsersController::class), - ], [], [], null, [], ['GET']) - ); + $adminCondition = 'request.headers.get("Host") matches "/'.config('expose.dashboard_subdomain').'./i"'; - $this->routes->add('admin.users.store', - new Route('/expose/users', [ - '_controller' => app(StoreUsersController::class), - ], [], [], null, [], ['POST']) - ); - - $this->routes->add('admin.users.delete', - new Route('/expose/users/delete/{id}', [ - '_controller' => app(DeleteUsersController::class), - ], [], [], null, [], ['DELETE']) - ); - - $this->routes->add('admin.sites.index', - new Route('/expose/sites', [ - '_controller' => app(ListSitesController::class), - ], [], [], null, [], ['GET']) - ); + $this->router->get('/', LoginController::class, $adminCondition); + $this->router->post('/', VerifyLoginController::class, $adminCondition); + $this->router->get('/users', ListUsersController::class, $adminCondition); + $this->router->post('/users', StoreUsersController::class, $adminCondition); + $this->router->delete('/users/delete/{id}', DeleteUsersController::class, $adminCondition); + $this->router->get('/sites', ListSitesController::class, $adminCondition); } protected function bindConfiguration() @@ -164,7 +145,7 @@ class Factory $this->addExposeRoutes(); - $urlMatcher = new UrlMatcher($this->routes, new RequestContext); + $urlMatcher = new UrlMatcher($this->router->getRoutes(), new RequestContext); $router = new Router($urlMatcher); diff --git a/app/Server/Http/Controllers/Admin/ListSitesController.php b/app/Server/Http/Controllers/Admin/ListSitesController.php index a3ab69e..707ab1c 100644 --- a/app/Server/Http/Controllers/Admin/ListSitesController.php +++ b/app/Server/Http/Controllers/Admin/ListSitesController.php @@ -26,8 +26,14 @@ class ListSitesController extends PostController public function handle(Request $request, ConnectionInterface $httpConnection) { + try { + $sites = $this->getView('server.sites.index', ['sites' => $this->connectionManager->getConnections()]); + } catch (\Exception $e) { + dump($e->getMessage()); + } + $httpConnection->send( - respond_html($this->getView('server.sites.index', ['sites' => $this->connectionManager->getConnections()])) + respond_html($sites) ); } } diff --git a/app/Server/Http/Controllers/Admin/LoginController.php b/app/Server/Http/Controllers/Admin/LoginController.php new file mode 100644 index 0000000..79ef5a1 --- /dev/null +++ b/app/Server/Http/Controllers/Admin/LoginController.php @@ -0,0 +1,25 @@ +send( + respond_html($this->getView('server.login')) + ); + } +} diff --git a/app/Server/Http/Controllers/Admin/VerifyLoginController.php b/app/Server/Http/Controllers/Admin/VerifyLoginController.php new file mode 100644 index 0000000..8e7aa03 --- /dev/null +++ b/app/Server/Http/Controllers/Admin/VerifyLoginController.php @@ -0,0 +1,51 @@ +database = $database; + } + + public function handle(Request $request, ConnectionInterface $httpConnection) + { + $this->database->query("SELECT * FROM users WHERE email = :email", ['email' => $request->email]) + ->then(function (Result $result) use ($httpConnection) { + if (!is_null($result->rows)) { + $httpConnection->send( + str(new Response( + 301, + ['Location' => '/users'] + )) + ); + } else { + $httpConnection->send( + str(new Response( + 301, + ['Location' => '/users'] + )) + ); + } + $httpConnection->close(); + }); + } +} diff --git a/app/Server/Http/Controllers/ControlMessageController.php b/app/Server/Http/Controllers/ControlMessageController.php index 20af63e..88edc52 100644 --- a/app/Server/Http/Controllers/ControlMessageController.php +++ b/app/Server/Http/Controllers/ControlMessageController.php @@ -78,6 +78,10 @@ class ControlMessageController implements MessageComponentInterface $this->verifyAuthToken($connection); } + if (! $this->hasValidSubdomain($connection, $data->subdomain)) { + return; + } + $connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection); $connection->send(json_encode([ @@ -122,4 +126,24 @@ class ControlMessageController implements MessageComponentInterface } }); } + + protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain): bool + { + if (! is_null($subdomain)) { + $controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain); + if (! is_null($controlConnection) || $subdomain === config('expose.dashboard_subdomain')) { + $connection->send(json_encode([ + 'event' => 'subdomainTaken', + 'data' => [ + 'subdomain' => $subdomain, + ] + ])); + $connection->close(); + + return false; + } + } + + return true; + } } diff --git a/app/Server/Http/Controllers/TunnelMessageController.php b/app/Server/Http/Controllers/TunnelMessageController.php index 9663d55..3e739a7 100644 --- a/app/Server/Http/Controllers/TunnelMessageController.php +++ b/app/Server/Http/Controllers/TunnelMessageController.php @@ -82,6 +82,7 @@ class TunnelMessageController extends PostController protected function prepareRequest(Request $request, ControlConnection $controlConnection): Request { $request->headers->set('Host', $controlConnection->host); + $request->headers->set('X-Forwarded-Proto', $request->isSecure() ? 'https' : 'http'); $request->headers->set('X-Expose-Request-ID', uniqid()); $request->headers->set('X-Exposed-By', config('app.name') . ' '. config('app.version')); $request->headers->set('X-Original-Host', "{$controlConnection->subdomain}.{$this->configuration->hostname()}:{$this->configuration->port()}"); diff --git a/app/Server/Http/RouteGenerator.php b/app/Server/Http/RouteGenerator.php new file mode 100644 index 0000000..b8e536e --- /dev/null +++ b/app/Server/Http/RouteGenerator.php @@ -0,0 +1,78 @@ +routes = new RouteCollection; + } + + public function getRoutes(): RouteCollection + { + return $this->routes; + } + + public function get(string $uri, $action, string $condition = '') + { + $this->addRoute('GET', $uri, $action, $condition); + } + + public function post(string $uri, $action, string $condition = '') + { + $this->addRoute('POST', $uri, $action, $condition); + } + + public function put(string $uri, $action, string $condition = '') + { + $this->addRoute('PUT', $uri, $action, $condition); + } + + public function patch(string $uri, $action, string $condition = '') + { + $this->addRoute('PATCH', $uri, $action, $condition); + } + + public function delete(string $uri, $action, string $condition = '') + { + $this->addRoute('DELETE', $uri, $action, $condition); + } + + public function addRoute(string $method, string $uri, $action, string $condition = '') + { + $this->routes->add("{$method}-($uri}", $this->getRoute($method, $uri, $action, $condition)); + } + + public function addSymfonyRoute(string $name, Route $route) + { + $this->routes->add($name, $route); + } + + protected function getRoute(string $method, string $uri, $action, string $condition = ''): Route + { + $action = is_subclass_of($action, MessageComponentInterface::class) + ? $this->createWebSocketsServer($action) + : app($action); + + return new Route($uri, ['_controller' => $action], [], [], null, [], [$method], $condition); + } + + protected function createWebSocketsServer(string $action): WsServer + { + $wServer = new WsServer(app($action)); + + $wServer->enableKeepAlive(app(LoopInterface::class)); + + return $wServer; + } +} diff --git a/app/Server/Http/Router.php b/app/Server/Http/Router.php new file mode 100644 index 0000000..6422796 --- /dev/null +++ b/app/Server/Http/Router.php @@ -0,0 +1,126 @@ +_matcher = $matcher; + $this->_noopController = new NoOpHttpServerController; + } + + /** + * {@inheritdoc} + * @throws \UnexpectedValueException If a controller is not \Ratchet\Http\HttpServerInterface + */ + public function onOpen(ConnectionInterface $conn, RequestInterface $request = null) + { + if (null === $request) { + throw new \UnexpectedValueException('$request can not be null'); + } + + $conn->controller = $this->_noopController; + + $uri = $request->getUri(); + + $context = $this->_matcher->getContext(); + $context->setMethod($request->getMethod()); + $context->setHost($uri->getHost()); + + $symfonyRequest = $this->createSymfonyRequest($request); + + try { + $route = $this->_matcher->matchRequest($symfonyRequest); + } catch (MethodNotAllowedException $nae) { + return $this->close($conn, 405, array('Allow' => $nae->getAllowedMethods())); + } catch (ResourceNotFoundException $nfe) { + return $this->close($conn, 404); + } + + if (is_string($route['_controller']) && class_exists($route['_controller'])) { + $route['_controller'] = new $route['_controller']; + } + + if (!($route['_controller'] instanceof HttpServerInterface)) { + throw new \UnexpectedValueException('All routes must implement Ratchet\Http\HttpServerInterface'); + } + + $parameters = []; + foreach ($route as $key => $value) { + if ((is_string($key)) && ('_' !== substr($key, 0, 1))) { + $parameters[$key] = $value; + } + } + $parameters = array_merge($parameters, parse_query($uri->getQuery() ?: '')); + + $request = $request->withUri($uri->withQuery(build_query($parameters))); + + $conn->controller = $route['_controller']; + $conn->controller->onOpen($conn, $request); + } + + /** + * {@inheritdoc} + */ + public function onMessage(ConnectionInterface $from, $msg) + { + $from->controller->onMessage($from, $msg); + } + + /** + * {@inheritdoc} + */ + public function onClose(ConnectionInterface $conn) + { + if (isset($conn->controller)) { + $conn->controller->onClose($conn); + } + } + + /** + * {@inheritdoc} + */ + public function onError(ConnectionInterface $conn, \Exception $e) + { + if (isset($conn->controller)) { + $conn->controller->onError($conn, $e); + } + } + + protected function createSymfonyRequest(RequestInterface $request) + { + $serverRequest = (new ServerRequest( + $request->getMethod(), + $request->getUri(), + $request->getHeaders(), + $request->getBody(), + $request->getProtocolVersion() + ))->withQueryParams(QueryParameters::create($request)->all()); + + return (new HttpFoundationFactory())->createRequest($serverRequest); + } +} diff --git a/composer.json b/composer.json index 874d4f7..ea66c87 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,6 @@ "ext-json": "*" }, "require-dev": { - "mockery/mockery": "^1.3.1", - "phpunit/phpunit": "^8.5", "bfunky/http-parser": "^2.2", "cboden/ratchet": "^0.4.2", "clue/buzz-react": "^2.7", @@ -33,11 +31,14 @@ "illuminate/validation": "^7.7", "laminas/laminas-http": "^2.11", "laravel-zero/framework": "^7.0", + "mockery/mockery": "^1.3.1", "namshi/cuzzle": "^2.0", "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^8.5", "ratchet/pawl": "^0.3.4", "react/socket": "^1.4", "riverline/multipart-parser": "^2.0", + "seregazhuk/react-promise-testing": "^0.4.0", "symfony/expression-language": "^5.0", "symfony/http-kernel": "^4.0|^5.0", "symfony/psr-http-message-bridge": "^2.0", diff --git a/composer.lock b/composer.lock index f762ed2..a2aaf62 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2696eac6d05332230aa69e21a6aee524", + "content-hash": "3199f5982eed399c719f72239be99a83", "packages": [], "packages-dev": [ { @@ -108,6 +108,59 @@ ], "time": "2020-01-27T23:08:40+00:00" }, + { + "name": "clue/block-react", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-block.git", + "reference": "2f516b28259c203d67c4c963772dd7e9db652737" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-block/zipball/2f516b28259c203d67c4c963772dd7e9db652737", + "reference": "2f516b28259c203d67c4c963772dd7e9db652737", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3.5", + "react/promise": "^2.7 || ^1.2.1", + "react/promise-timer": "^1.5" + }, + "require-dev": { + "phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "Lightweight library that eases integrating async components built for ReactPHP in a traditional, blocking environment.", + "homepage": "https://github.com/clue/reactphp-block", + "keywords": [ + "async", + "await", + "blocking", + "event loop", + "promise", + "reactphp", + "sleep", + "synchronous" + ], + "time": "2019-04-09T11:45:04+00:00" + }, { "name": "clue/buzz-react", "version": "v2.7.0", @@ -5438,6 +5491,55 @@ "homepage": "https://github.com/sebastianbergmann/version", "time": "2016-10-03T07:35:21+00:00" }, + { + "name": "seregazhuk/react-promise-testing", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/seregazhuk/php-react-promise-testing.git", + "reference": "a56481a6feae2cb51f91d554af41a66802a01a37" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/seregazhuk/php-react-promise-testing/zipball/a56481a6feae2cb51f91d554af41a66802a01a37", + "reference": "a56481a6feae2cb51f91d554af41a66802a01a37", + "shasum": "" + }, + "require": { + "clue/block-react": "^1.3", + "php": ">=7.2", + "phpunit/phpunit": "^8.2", + "react/promise": "^2.7" + }, + "require-dev": { + "codeclimate/php-test-reporter": "^0.4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "seregazhuk\\React\\PromiseTesting\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Zhuk", + "email": "seregazhuk88@gmail.com" + } + ], + "description": "PHPUnit-based library for testing ReactPHP promises", + "homepage": "https://github.com/seregazhuk/php-react-promise-testing", + "keywords": [ + "promise", + "reactphp", + "test", + "testing" + ], + "time": "2020-03-09T12:02:56+00:00" + }, { "name": "symfony/cache", "version": "v5.0.7", diff --git a/database/migrations/01_create_users_table.sql b/database/migrations/01_create_users_table.sql index 307abb5..ef3d850 100644 --- a/database/migrations/01_create_users_table.sql +++ b/database/migrations/01_create_users_table.sql @@ -1,6 +1,7 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name STRING NOT NULL, + email STRING, auth_token STRING, created_at DATETIME, updated_at DATETIME diff --git a/resources/views/server/layouts/app.twig b/resources/views/server/layouts/app.twig new file mode 100644 index 0000000..3707775 --- /dev/null +++ b/resources/views/server/layouts/app.twig @@ -0,0 +1,67 @@ + + + + + + +
+ + +
+
+
+

+ {% block title %}{% endblock %} +

+
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+{% block scripts %}{% endblock %} + + diff --git a/resources/views/server/login.twig b/resources/views/server/login.twig new file mode 100644 index 0000000..2eaab46 --- /dev/null +++ b/resources/views/server/login.twig @@ -0,0 +1,47 @@ + + + + + + +
+
+
+

+ Expose +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+ +
+
+
+
+ diff --git a/resources/views/server/sites/index.twig b/resources/views/server/sites/index.twig index 1adcb93..74d8259 100644 --- a/resources/views/server/sites/index.twig +++ b/resources/views/server/sites/index.twig @@ -1,194 +1,94 @@ - - - - - - -
- - -
-
-
-

- Sites -

-
-
-
-
-
-
-
- - - - - - - - - - - {% for site in sites %} - - - - - - - {% endfor %} - -
- Host - - Subdomain - - Shared At -
- {{ site.host }} - - {{ site.subdomain }}.localhost:8080 - - {{ site.shared_at }} - - Visit -
-
-
-
-
-
-
- - - + }) + +{% endblock %} diff --git a/resources/views/server/users/index.twig b/resources/views/server/users/index.twig index 8236e30..7691fd3 100644 --- a/resources/views/server/users/index.twig +++ b/resources/views/server/users/index.twig @@ -1,238 +1,93 @@ - - - - - - -
- - -
-
-
-

- Users -

-
-
-
-
-
-
-
-
-
- -
-
- - @{ userForm.errors.name[0] } - - -
-
-
-
-
-
-
-
- - - - - - -
-
-
-
-
-
- - - - - - - - - - - - - - - - - -
- Name - - Auth-Token - - Created At -
- @{ user.name } - - @{ user.auth_token } - - @{ user.created_at } - - Edit - Delete -
-
-
-
-
-
-
- - - + }) + +{% endblock %} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 06ece2c..0c5acb6 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit; -use Tests\TestCase; +use seregazhuk\React\PromiseTesting\TestCase; class ExampleTest extends TestCase {