diff --git a/.github/workflows/php-unit-tests.yml b/.github/workflows/php-unit-tests.yml index 7bd972b..f1bbfa6 100644 --- a/.github/workflows/php-unit-tests.yml +++ b/.github/workflows/php-unit-tests.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v3 - name: Use coverage? - if: ${{ (matrix.php-versions == '7.4') && (matrix.dependency-versions == 'highest') && (matrix.container-versions == '^2') }} + if: ${{ (matrix.php-versions == '8.0') && (matrix.dependency-versions == 'highest') && (matrix.container-versions == '^2') }} run: echo "USE_COVERAGE=yes" >> $GITHUB_ENV - name: Setup PHP @@ -46,7 +46,9 @@ jobs: dependency-versions: ${{ matrix.dependency-versions }} - name: Run unit tests - run: composer tests:${{ ((env.USE_COVERAGE == 'yes') && 'codecov') || 'no-cov' }} + run: + ./vendor/bin/phpunit --atleast-version 9 && ./vendor/bin/phpunit --migrate-configuration || echo 'Config does not need updates.' + ./vendor/bin/phpunit ${{ ((env.USE_COVERAGE == 'yes') && '--coverage-clover coverage.xml') || '--no-coverage' }} - name: Update coverage if: ${{ env.USE_COVERAGE == 'yes' }} diff --git a/composer.json b/composer.json index 15ad0b1..c1f8089 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,6 @@ "@psalm" ], "tests": "@php ./vendor/phpunit/phpunit/phpunit", - "tests:codecov": "@php ./vendor/phpunit/phpunit/phpunit --coverage-clover coverage.xml", "tests:no-cov": "@php ./vendor/phpunit/phpunit/phpunit --no-coverage" }, "config": { diff --git a/docs/Applicaton-flow.md b/docs/Applicaton-flow.md new file mode 100644 index 0000000..7f259aa --- /dev/null +++ b/docs/Applicaton-flow.md @@ -0,0 +1,100 @@ +# The application flow + +Modularity implements its application flow in two stages: + +- First, the application's dependencies tree is "composed" by collecting services declared in modules, adding sub-containers, and connecting other applications. +- After that, the application dependency tree is locked, and the services are "consumed" to execute their behavior. + +The `Package` class implements the two stages above, respectively, in the two methods: + +- **`Package::build()`** +- **`Package::boot()`** + +For convenience, `Package::boot()` is "smart enough" to call `build()` if it was not called before, so the following code (that makes the two stages evident): + +```php +Package::new($properties)->build()->boot(); +``` + +is entirely equivalent to the following: + +```php +Package::new($properties)->boot(); +``` + +Both stages are implemented through a series of *steps*, and the application status progresses as the steps are complete. In the process, a few action hooks are fired to allow external code to interact with the flow. + +At any point of the flow, by holding an instance of the `Package` is possible to inspect the current status via `Package::statusIs()`, passing as an argument one of the `Package::STATUS_*` constants. + + +## Building stage + +1. Upon instantiation, the `Package` status is at **`Package::STATUS_IDLE`** +2. Default modules can be added by calling **`Package::addModule()`** on the instance. +3. The **`Package::ACTION_INIT`** action hook is fired, passing the package instance as an argument. That allows external code to add modules. +4. The `Package` status moves to **`Package::STATUS_INITIALIZED`**. The "building" stage is completed, and no more modules can be added. + + +## Booting stage + +1. When the booting stage begins, the `Package` status moves to **`Package::STATUS_MODULES_ADDED`**. +2. A read-only PSR-11 container is created. It can lazily resolve the dependency tree defined in the previous stage. +3. **All executables modules run**. That is when all the application behavior happens. Note: Because the container is "lazy", only the consumed services are resolved. The `Package` never executes factory callbacks for services "registered" in the previous stage but not used in this stage. +4. The `Package` status moves to **`Package::STATUS_READY`**. +5. The **`Package::ACTION_READY`** action hook is fired, passing the package instance as an argument. External code hooking that action can access the read-only container instance, resolve services, and perform additional actions but not register modules. +6. The `Package` status moves to **`Package::STATUS_BOOTED`**. The booting stage is completed. `Package::boot()` returns true. + + +## The "failure flow" + +The steps listed above for the two stages represent the "happy paths". If any exception is thrown at any of the steps above, the flows are halted and the "failure flow" starts. + +### When the failure starts during the "building" stage + +1. The `Package` status moves to **`Package::STATUS_FAILED`**. +2. The **`Package::ACTION_FAILED_BUILD`** action hook is fired, passing the raised `Throwable` as an argument. +3. If the `Package`'s `Properties` instance is in "debug mode" (`Properties::isDebug()` returns `true`), the exception bubbles up, and the flow stops here. +4. If the `Properties` instance is _not_ in "debug mode", the **`Package::ACTION_FAILED_BOOT`** action hook is fired, passing a `Throwable` whose `previous` property is the `Throwable` thrown during the building stage. The "previous hierarchy" could be several levels if during the building stage many failures happened. +5. `Package::boot()` returns false. + +### When the failure starts during the "booting" stage + +1. The `Package` status moves to **`Package::STATUS_FAILED`**. +2. The **`Package::ACTION_FAILED_BOOT`** action hook is fired, passing the raised `Throwable` as an argument. +3. If the `Package`'s `Properties` instance is in "debug mode" (`Properties::isDebug()` returns `true`), the exception bubbles up, and the flow stops here. +4. `Package::boot()` returns false. + + +## A note about default modules passed to boot() + +The `Package::boot()` method accepts a list of modules. That has been deprecated since Modularity v1.7. + +Considering that `Package::boot()` represents the "booting" stage that is supposed to happen *after* the "building" stage, it might be hard to figure out where the addition of those modules fits in the flows described above. + +When `Package::boot()` is called without calling `Package::build()` first, as in: + +```php +Package::new($properties)->boot(new ModuleOne(), new ModuleTwo()); +``` + +The code is equivalent to the following: + +```php +Package::new($properties)->addModule(new ModuleOne())->addModule(new ModuleTwo())->boot(); +``` + +So the "building" flow is respected. + +However, when `Package::boot()` is called after `Package::build()`, as in: + +```php +Package::new($properties)->build()->boot(new ModuleOne(), new ModuleTwo()); +``` + +The `Package` is at the end of the "building" flow after `Package::build()` is called, but it must "jump" back in the middle of "building" flow to add the modules. + +In fact, after `Package::build()` is called the application status is at `Package::STATUS_INITIALIZED`, and no more modules can be added. + +However, for backward compatibility reasons, in that case, the `Package` temporarily "hacks" the status back to `Package::STATUS_IDLE` so modules can be added, and then resets it to `Package::STATUS_INITIALIZED` so that the "booting" stage can start as usual. + +This "hack" is why passing modules to `Package::boot()` has been deprecated and will be removed in the next major version when backward compatibility breaks are allowed. diff --git a/docs/Package.md b/docs/Package.md index 95c71d5..06066fb 100644 --- a/docs/Package.md +++ b/docs/Package.md @@ -56,6 +56,8 @@ Retrieve the current status of the Application. Following are available: - `Package::STATUS_IDLE` - before Application is booted. - `Package::STATUS_INITIALIZED` - after first init action is triggered. +- `Package::STATUS_MODULES_ADDED` - after all modules have been added. +- `Package::STATUS_READY` - after the "ready" action has been fired. - `Package::STATUS_BOOTED` - Application has successfully booted. - `Package::STATUS_FAILED_BOOT` - when Application did not boot properly. @@ -100,7 +102,7 @@ add_action( ); ``` -By providing the `Acme\plugin()`-function, you’ll enable externals to hook into your Application: +By providing the `Acme\plugin()` function, you’ll enable external code to hook into your application: ```php addModule(new ModuleOne()) + ->addModule(new ModuleTwo()) + } + return $package; +} +``` + +In unit test it will be possible (as of v1.7+) to do something like the following: + +```php +$myService = plugin()->build()->container()->get(MyService::class); +static::assertTrue($myService->isValid()); +``` + +### Booting a built container + +The `Package::boot()` method can be called on already built package. + +For example, the following is a valid unit test code: + +```php +$plugin = plugin()->build(); +$myService = $plugin->container()->get(MyService::class); + +static::assertTrue($myService->isValid()); +static::assertFalse($myService->isBooted()); + +$plugin->boot(); + +static::assertTrue($myService->isBooted()); +``` + +### Deprecated boot parameters + +Before Modularity v1.7.0, it was an accepted practice to pass default modules to `Package::boot()`, +as in: + +```php +add_action( + 'plugins_loaded', + static function(): void { + plugin()->boot(new ModuleOne(), new ModuleTwo()); + } +); +``` + +This is now deprecated to allow a better separation of the "building" and "booting" steps. + +While it still works (and it will work up to version 2.0), it will emit a deprecation notice. + +The replacement is using `Package::addModule()`: + +```php +plugin()->addModule(new ModuleOne())->addModule(new ModuleTwo())->boot(); +``` + +There's only one case in which calling `Package::boot()` with default modules will throw an +exception (besides triggering a deprecated notice), that is when a passed modules was not added +before `Package::addModule()` and an instance of the container was already obtained from the package. + +For example, this will throw an exception: + +```php +$plugin = plugin()->build(); + +// Now that container is built, passing modules to `boot()` will raise an exception, because we +// can't add new modules to an already "compiled" container being that read-only. +$container = $plugin->container(); + +$plugin->boot(new ModuleOne()); +``` + +To prevent the exception it would be necessary to add the module before calling `build()`, or alternatively, to call `$plugin->boot(new ModuleOne())` _before_ calling `$plugin->container()`. +In this latter case the exception is not thrown, but the deprecation will still be emitted. ## Connecting packages @@ -180,39 +270,3 @@ Thanks to that, all plugins will be able to access the library's services in the ### Accessing connected packages' properties In modules, we can access package properties calling `$container->get(Package::PROPERTIES)`. If we'd like to access any connected package properties, we could do that using a key whose format is: `sprintf('%s.%s', $connectedPackage->name(), Package::PROPERTIES)`. - - - -## What happens on Package::boot()? - -When booting your Application, following will happen inside: - -**0. Package::statusIs(Package::STATUS_IDLE);** - -Application is idle and ready to start. - -**1. Register default Modules** - -Default Modules which are injected before `Package::boot()` will be registered first by iterating over all Modules and calling `Package::addModule()`. - -**2. Package::ACTION_INIT** - -A custom WordPress action will be triggered first to allow registration of additional Modules via `Package::addModule()` by accessing the `Package`-class. Application will change into `Package::STATUS_INITIALIZED` afterwards. - -Newly registered Modules via that hook will be executed after the default Modules which are injected before the `Package::boot()`-method. - -**3. Compile read-only Container** - -The default primary PSR-Container is generated by the ContainerConfigurator by injecting all Factories, Extension and child PSR-Containers into it. - -**4. Execute all ExecutableModules** - -After collecting all ExecutableModules, the Package-class will now iterate over all ExecutableModules and execute them by injecting the default primary PSR-Container. - -**5. Package::ACTION_READY** - -Last but not least, `Package::boot()` will trigger custom WordPress Action which allows you to access the Package-class again for the purpose of debugging all Modules. - -**6. Done** - -The package was either successfully booted and state changed to `Package::STATUS_BOOTED` or failed booting due some exceptions and state was changed to `Package::STATUS_FAILED_BOOT`. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e13cfa4..58ff95a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,10 +1,11 @@ diff --git a/src/Container/PackageProxyContainer.php b/src/Container/PackageProxyContainer.php index 9aaf574..d5e3954 100644 --- a/src/Container/PackageProxyContainer.php +++ b/src/Container/PackageProxyContainer.php @@ -36,7 +36,6 @@ public function __construct(Package $package) */ public function get(string $id) { - assert(is_string($id)); $this->assertPackageBooted($id); return $this->container->get($id); @@ -50,8 +49,6 @@ public function get(string $id) */ public function has(string $id): bool { - assert(is_string($id)); - return $this->tryContainer() && $this->container->has($id); } @@ -67,7 +64,11 @@ private function tryContainer(): bool return true; } - if ($this->package->statusIs(Package::STATUS_BOOTED)) { + /** TODO: We need a better way to deal with status checking besides equality */ + if ( + $this->package->statusIs(Package::STATUS_READY) + || $this->package->statusIs(Package::STATUS_BOOTED) + ) { $this->container = $this->package->container(); } @@ -90,8 +91,8 @@ private function assertPackageBooted(string $id): void $name = $this->package->name(); $status = $this->package->statusIs(Package::STATUS_FAILED) - ? 'failed booting' - : 'is not booted yet'; + ? 'is errored' + : 'is not ready yet'; throw new class ("Error retrieving service {$id} because package {$name} {$status}.") extends \Exception diff --git a/src/Package.php b/src/Package.php index e346d84..6c7e712 100644 --- a/src/Package.php +++ b/src/Package.php @@ -72,7 +72,22 @@ class Package public const ACTION_READY = 'ready'; /** - * Custom action which is triggered when application failed to boot. + * Custom action which is triggered when a failure happens during the building stage. + * + * @example + * + * $package = Package::new(); + * + * add_action( + * $package->hookName(Package::ACTION_FAILED_BUILD), + * $callback + * ); + * + */ + public const ACTION_FAILED_BUILD = 'failed-build'; + + /** + * Custom action which is triggered when a failure happens during the booting stage. * * @example * @@ -129,6 +144,8 @@ class Package */ public const STATUS_IDLE = 2; public const STATUS_INITIALIZED = 4; + public const STATUS_MODULES_ADDED = 5; + public const STATUS_READY = 7; public const STATUS_BOOTED = 8; public const STATUS_FAILED = -8; @@ -175,6 +192,21 @@ class Package */ private $containerConfigurator; + /** + * @var bool + */ + private $built = false; + + /** + * @var bool + */ + private $hasContainer = false; + + /** + * @var \Throwable|null + */ + private $lastError = null; + /** * @param Properties $properties * @param ContainerInterface[] $containers @@ -211,23 +243,36 @@ static function () use ($properties) { */ public function addModule(Module $module): Package { - $this->assertStatus(self::STATUS_IDLE, 'access Container'); - - $registeredServices = $this->addModuleServices($module, self::MODULE_REGISTERED); - $registeredFactories = $this->addModuleServices($module, self::MODULE_REGISTERED_FACTORIES); - $extended = $this->addModuleServices($module, self::MODULE_EXTENDED); - $isExecutable = $module instanceof ExecutableModule; - - // ExecutableModules are collected and executed on Package::boot() - // when the Container is being compiled. - if ($isExecutable) { - /** @var ExecutableModule $module */ - $this->executables[] = $module; - } + try { + $this->assertStatus(self::STATUS_IDLE, sprintf('add module %s', $module->id())); + + $registeredServices = $this->addModuleServices( + $module, + self::MODULE_REGISTERED + ); + $registeredFactories = $this->addModuleServices( + $module, + self::MODULE_REGISTERED_FACTORIES + ); + $extended = $this->addModuleServices( + $module, + self::MODULE_EXTENDED + ); + $isExecutable = $module instanceof ExecutableModule; + + // ExecutableModules are collected and executed on Package::boot() + // when the Container is being compiled. + if ($isExecutable) { + /** @var ExecutableModule $module */ + $this->executables[] = $module; + } - $added = $registeredServices || $registeredFactories || $extended || $isExecutable; - $status = $added ? self::MODULE_ADDED : self::MODULE_NOT_ADDED; - $this->moduleProgress($module->id(), $status); + $added = $registeredServices || $registeredFactories || $extended || $isExecutable; + $status = $added ? self::MODULE_ADDED : self::MODULE_NOT_ADDED; + $this->moduleProgress($module->id(), $status); + } catch (\Throwable $throwable) { + $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); + } return $this; } @@ -239,68 +284,107 @@ public function addModule(Module $module): Package */ public function connect(Package $package): bool { - if ($package === $this) { - return false; - } + try { + if ($package === $this) { + return false; + } - $packageName = $package->name(); - $errorData = ['package' => $packageName, 'status' => $this->status]; + $packageName = $package->name(); + $errorData = ['package' => $packageName, 'status' => $this->status]; + $errorMessage = "Failed connecting package {$packageName}"; - // Don't connect, if already connected - if (array_key_exists($packageName, $this->connectedPackages)) { - do_action( - $this->hookName(self::ACTION_FAILED_CONNECTION), - $packageName, - new \WP_Error('already_connected', 'already connected', $errorData) + // Don't connect, if already connected + if (array_key_exists($packageName, $this->connectedPackages)) { + $error = "{$errorMessage} because it was already connected."; + do_action( + $this->hookName(self::ACTION_FAILED_CONNECTION), + $packageName, + new \WP_Error('already_connected', $error, $errorData) + ); + + throw new \Exception($error, 0, $this->lastError); + } + + // Don't connect, if already booted or boot failed + $failed = $this->statusIs(self::STATUS_FAILED); + if ($failed || $this->statusIs(self::STATUS_BOOTED)) { + $status = $failed ? 'errored' : 'booted'; + $error = "{$errorMessage} to a {$status} package."; + do_action( + $this->hookName(self::ACTION_FAILED_CONNECTION), + $packageName, + new \WP_Error("no_connect_on_{$status}", $error, $errorData) + ); + + throw new \Exception($error, 0, $this->lastError); + } + + $this->connectedPackages[$packageName] = true; + + // We put connected package's properties in this package's container, so that in modules + // "run" method we can access them if we need to. + $this->containerConfigurator->addService( + sprintf('%s.%s', $package->name(), self::PROPERTIES), + static function () use ($package): Properties { + return $package->properties(); + } ); - return false; - } + // If the other package is booted, we can obtain a container, otherwise + // we build a proxy container + $container = $package->statusIs(self::STATUS_BOOTED) + ? $package->container() + : new PackageProxyContainer($package); + + $this->containerConfigurator->addContainer($container); - // Don't connect, if already booted or boot failed - if (in_array($this->status, [self::STATUS_BOOTED, self::STATUS_FAILED], true)) { - $this->connectedPackages[$packageName] = false; do_action( - $this->hookName(self::ACTION_FAILED_CONNECTION), + $this->hookName(self::ACTION_PACKAGE_CONNECTED), $packageName, - new \WP_Error('no_connect_status', 'no connect status', $errorData) + $this->status, + $container instanceof PackageProxyContainer ); - return false; - } - - $this->connectedPackages[$packageName] = true; - - // We put connected package's properties in this package's container, so that in modules - // "run" method we can access them if we need to. - $this->containerConfigurator->addService( - sprintf('%s.%s', $package->name(), self::PROPERTIES), - static function () use ($package): Properties { - return $package->properties(); + return true; + } catch (\Throwable $throwable) { + if (isset($packageName)) { + $this->connectedPackages[$packageName] = false; } - ); + $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); - // If the other package is booted, we can obtain a container, otherwise - // we build a proxy container - $container = $package->statusIs(self::STATUS_BOOTED) - ? $package->container() - : new PackageProxyContainer($package); + return false; + } + } - $this->containerConfigurator->addContainer($container); + /** + * @return static + */ + public function build(): Package + { + try { + // Don't allow building the application multiple times. + $this->assertStatus(self::STATUS_IDLE, 'build package'); - do_action( - $this->hookName(self::ACTION_PACKAGE_CONNECTED), - $packageName, - $this->status, - $container instanceof PackageProxyContainer - ); + do_action( + $this->hookName(self::ACTION_INIT), + $this + ); + // Changing the status here ensures we can not call this method again, and also we can not + // add new modules, because both this and `addModule()` methods check for idle status. + // For backward compatibility, adding new modules via `boot()` will still be possible, even + // if deprecated, at the condition that the container was not yet accessed at that point. + $this->progress(self::STATUS_INITIALIZED); + } catch (\Throwable $throwable) { + $this->handleFailure($throwable, self::ACTION_FAILED_BUILD); + } finally { + $this->built = true; + } - return true; + return $this; } /** - * @param Module ...$defaultModules - * + * @param Module ...$defaultModules Deprecated, use `addModule()` to add default modules. * @return bool * * @throws \Throwable @@ -308,35 +392,26 @@ static function () use ($package): Properties { public function boot(Module ...$defaultModules): bool { try { - // don't allow to boot the application multiple times. - $this->assertStatus(self::STATUS_IDLE, 'execute boot'); + // Call build() if not called yet, and ensure any new module passed here is added + // as well, throwing if the container was already built. + $this->doBuild(...$defaultModules); - // Add default Modules to the Application. - array_map([$this, 'addModule'], $defaultModules); + // Don't allow booting the application multiple times. + $this->assertStatus(self::STATUS_MODULES_ADDED, 'boot application', '<'); + $this->assertStatus(self::STATUS_FAILED, 'boot application', '!='); - do_action( - $this->hookName(self::ACTION_INIT), - $this - ); - // we want to lock adding new Modules and Containers now - // to process everything and be able to compile the container. - $this->progress(self::STATUS_INITIALIZED); + $this->progress(self::STATUS_MODULES_ADDED); - if (count($this->executables) > 0) { - $this->doExecute(); - } + $this->doExecute(); + + $this->progress(self::STATUS_READY); do_action( $this->hookName(self::ACTION_READY), $this ); } catch (\Throwable $throwable) { - $this->progress(self::STATUS_FAILED); - do_action($this->hookName(self::ACTION_FAILED_BOOT), $throwable); - - if ($this->properties->isDebug()) { - throw $throwable; - } + $this->handleFailure($throwable, self::ACTION_FAILED_BOOT); return false; } @@ -346,6 +421,60 @@ public function boot(Module ...$defaultModules): bool return true; } + /** + * @param Module ...$defaultModules + * @return void + */ + private function doBuild(Module ...$defaultModules): void + { + if ($defaultModules) { + $this->deprecatedArgument( + sprintf( + 'Passing default modules to %1$s::boot() is deprecated since version 1.7.0.' + . ' Please add modules via %1$s::addModule().', + __CLASS__ + ), + __METHOD__, + '1.7.0' + ); + } + + if (!$this->built) { + array_map([$this, 'addModule'], $defaultModules); + $this->build(); + + return; + } + + if ( + !$defaultModules + || ($this->status >= self::STATUS_MODULES_ADDED) + || ($this->statusIs(self::STATUS_FAILED)) + ) { + // if we don't have default modules, there's nothing to do, and if the status is beyond + // "modules added" or is failed, we do nothing as well and let `boot()` throw. + return; + } + + $backup = $this->status; + + try { + // simulate idle status to prevent `addModule()` from throwing + // only if we don't have a container yet + $this->hasContainer or $this->status = self::STATUS_IDLE; + + foreach ($defaultModules as $defaultModule) { + // If a module was added by `build()` or `addModule()` we can skip it, a + // deprecation was trigger to make it noticeable without breakage + if (!$this->moduleIs($defaultModule->id(), self::MODULE_ADDED)) { + $this->addModule($defaultModule); + } + } + } finally { + $this->status = $backup; + } + } + /** * @param Module $module * @param string $status @@ -507,7 +636,8 @@ public function properties(): Properties */ public function container(): ContainerInterface { - $this->assertStatus(self::STATUS_INITIALIZED, 'access Container', '>='); + $this->assertStatus(self::STATUS_INITIALIZED, 'obtain the container instance', '>='); + $this->hasContainer = true; return $this->containerConfigurator->createReadOnlyContainer(); } @@ -538,6 +668,25 @@ public function statusIs(int $status): bool return $this->status === $status; } + /** + * @param \Throwable $throwable + * @param Package::ACTION_FAILED_* $action + * @return void + * @throws \Throwable + */ + private function handleFailure(\Throwable $throwable, string $action): void + { + $this->progress(self::STATUS_FAILED); + $hook = $this->hookName($action); + did_action($hook) or do_action($hook, $throwable); + + if ($this->properties->isDebug()) { + throw $throwable; + } + + $this->lastError = $throwable; + } + /** * @param int $status * @param string $action @@ -549,7 +698,30 @@ public function statusIs(int $status): bool private function assertStatus(int $status, string $action, string $operator = '=='): void { if (!version_compare((string) $this->status, (string) $status, $operator)) { - throw new \Exception(sprintf("Can't %s at this point of application.", $action)); + throw new \Exception( + sprintf("Can't %s at this point of application.", $action), + 0, + $this->lastError + ); + } + } + + /** + * Similar to WP's `_deprecated_argument()`, but executes regardless of WP_DEBUG and without + * translated message (so without attempting loading translation files). + * + * @param string $message + * @param string $function + * @param string $version + * + * @return void + */ + private function deprecatedArgument(string $message, string $function, string $version): void + { + do_action('deprecated_argument_run', $function, $message, $version); + + if (apply_filters('deprecated_argument_trigger_error', true)) { + trigger_error($message, \E_USER_DEPRECATED); } } } diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index 19a1417..fd7add5 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -13,22 +13,37 @@ use Inpsyde\Modularity\Properties\Properties; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use Mockery\MockInterface; +use PHPUnit\Framework\Error\Deprecated; use PHPUnit\Framework\TestCase as FrameworkTestCase; abstract class TestCase extends FrameworkTestCase { use MockeryPHPUnitIntegration; + /** + * @var int|null + */ + private $currentErrorReporting = null; + + /** + * @return void + */ protected function setUp(): void { parent::setUp(); Monkey\setUp(); } + /** + * @return void + */ protected function tearDown(): void { Monkey\tearDown(); parent::tearDown(); + if (is_int($this->currentErrorReporting)) { + error_reporting($this->currentErrorReporting); + } } /** @@ -43,8 +58,8 @@ protected function mockProperties( ): Properties { $stub = \Mockery::mock(Properties::class); - $stub->shouldReceive('basename')->andReturn($basename); - $stub->shouldReceive('isDebug')->andReturn($isDebug); + $stub->allows('basename')->andReturn($basename); + $stub->allows('isDebug')->andReturn($isDebug); return $stub; } @@ -59,22 +74,22 @@ protected function mockModule(string $id = 'module', string ...$interfaces): Mod $interfaces or $interfaces[] = Module::class; $stub = \Mockery::mock(...$interfaces); - $stub->shouldReceive('id')->andReturn($id); + $stub->allows('id')->andReturn($id); if (in_array(ServiceModule::class, $interfaces, true) ) { - $stub->shouldReceive('services')->byDefault()->andReturn([]); + $stub->allows('services')->byDefault()->andReturn([]); } if (in_array(FactoryModule::class, $interfaces, true) ) { - $stub->shouldReceive('factories')->byDefault()->andReturn([]); + $stub->allows('factories')->byDefault()->andReturn([]); } if (in_array(ExtendingModule::class, $interfaces, true) ) { - $stub->shouldReceive('extensions')->byDefault()->andReturn([]); + $stub->allows('extensions')->byDefault()->andReturn([]); } if (in_array(ExecutableModule::class, $interfaces, true) ) { - $stub->shouldReceive('run')->byDefault()->andReturn(false); + $stub->allows('run')->byDefault()->andReturn(false); } return $stub; @@ -95,4 +110,39 @@ protected function stubServices(string ...$ids): array return $services; } + + /** + * @return void + */ + protected function ignoreDeprecations(): void + { + $this->currentErrorReporting = error_reporting(); + error_reporting($this->currentErrorReporting & ~\E_DEPRECATED & ~\E_USER_DEPRECATED); + } + + /** + * @return void + */ + protected function convertDeprecationsToExceptions(): void + { + $this->currentErrorReporting = error_reporting(); + error_reporting($this->currentErrorReporting | \E_DEPRECATED | \E_USER_DEPRECATED); + + set_error_handler( + static function (int $code, string $msg, ?string $file = null, ?int $line = null): void { + throw new Deprecated($msg, $code, $file ?? '', $line ?? 0); + }, + \E_DEPRECATED | \E_USER_DEPRECATED + ); + } + + /** + * @param \Throwable $throwable + * @param string $pattern + * @return void + */ + protected function assertThrowableMessageMatches(\Throwable $throwable, string $pattern): void + { + static::assertSame(1, preg_match("/{$pattern}/i", $throwable->getMessage())); + } } diff --git a/tests/unit/Container/PackageProxyContainerTest.php b/tests/unit/Container/PackageProxyContainerTest.php new file mode 100644 index 0000000..3ce1ab2 --- /dev/null +++ b/tests/unit/Container/PackageProxyContainerTest.php @@ -0,0 +1,45 @@ +mockProperties()); + + $container = new PackageProxyContainer($package); + static::assertFalse($container->has('test')); + + $this->expectExceptionMessageMatches('/is not ready yet/i'); + $container->get('test'); + } + + /** + * @test + */ + public function testAccessingFailedPackageEarlyThrows(): void + { + $package = Package::new($this->mockProperties()); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_INIT)) + ->once() + ->andThrow(new \Error()); + + $container = new PackageProxyContainer($package->build()); + static::assertFalse($container->has('test')); + + $this->expectExceptionMessageMatches('/is errored/i'); + $container->get('test'); + } +} diff --git a/tests/unit/PackageTest.php b/tests/unit/PackageTest.php index d0c45a1..f3f15a7 100644 --- a/tests/unit/PackageTest.php +++ b/tests/unit/PackageTest.php @@ -92,9 +92,9 @@ public function testBootWithEmptyModule(): void $moduleStub = $this->mockModule($expectedId); $propertiesStub = $this->mockProperties('name', false); - $package = Package::new($propertiesStub); + $package = Package::new($propertiesStub)->addModule($moduleStub); - static::assertTrue($package->boot($moduleStub)); + static::assertTrue($package->boot()); static::assertTrue($package->moduleIs($expectedId, Package::MODULE_NOT_ADDED)); static::assertFalse($package->moduleIs($expectedId, Package::MODULE_REGISTERED)); static::assertFalse($package->moduleIs($expectedId, Package::MODULE_REGISTERED_FACTORIES)); @@ -114,11 +114,11 @@ public function testBootWithServiceModule(): void $serviceId = 'service-id'; $module = $this->mockModule($moduleId, ServiceModule::class); - $module->shouldReceive('services')->andReturn($this->stubServices($serviceId)); + $module->expects('services')->andReturn($this->stubServices($serviceId)); - $package = Package::new($this->mockProperties()); + $package = Package::new($this->mockProperties())->addModule($module); - static::assertTrue($package->boot($module)); + static::assertTrue($package->boot()); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_NOT_ADDED)); static::assertTrue($package->moduleIs($moduleId, Package::MODULE_REGISTERED)); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_REGISTERED_FACTORIES)); @@ -136,11 +136,11 @@ public function testBootWithFactoryModule(): void $factoryId = 'factory-id'; $module = $this->mockModule($moduleId, FactoryModule::class); - $module->shouldReceive('factories')->andReturn($this->stubServices($factoryId)); + $module->expects('factories')->andReturn($this->stubServices($factoryId)); - $package = Package::new($this->mockProperties()); + $package = Package::new($this->mockProperties())->addModule($module); - static::assertTrue($package->boot($module)); + static::assertTrue($package->boot()); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_NOT_ADDED)); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_REGISTERED)); static::assertTrue($package->moduleIs($moduleId, Package::MODULE_REGISTERED_FACTORIES)); @@ -158,11 +158,11 @@ public function testBootWithExtendingModuleWithNonExistingService(): void $extensionId = 'extension-id'; $module = $this->mockModule($moduleId, ExtendingModule::class); - $module->shouldReceive('extensions')->andReturn($this->stubServices($extensionId)); + $module->expects('extensions')->andReturn($this->stubServices($extensionId)); - $package = Package::new($this->mockProperties()); + $package = Package::new($this->mockProperties())->addModule($module); - static::assertTrue($package->boot($module)); + static::assertTrue($package->boot()); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_NOT_ADDED)); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_REGISTERED)); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_REGISTERED_FACTORIES)); @@ -181,12 +181,12 @@ public function testBootWithExtendingModuleWithExistingService(): void $serviceId = 'service-id'; $module = $this->mockModule($moduleId, ServiceModule::class, ExtendingModule::class); - $module->shouldReceive('services')->andReturn($this->stubServices($serviceId)); - $module->shouldReceive('extensions')->andReturn($this->stubServices($serviceId)); + $module->expects('services')->andReturn($this->stubServices($serviceId)); + $module->expects('extensions')->andReturn($this->stubServices($serviceId)); - $package = Package::new($this->mockProperties()); + $package = Package::new($this->mockProperties())->addModule($module); - static::assertTrue($package->boot($module)); + static::assertTrue($package->boot()); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_NOT_ADDED)); static::assertTrue($package->moduleIs($moduleId, Package::MODULE_REGISTERED)); static::assertFalse($package->moduleIs($moduleId, Package::MODULE_REGISTERED_FACTORIES)); @@ -196,82 +196,85 @@ public function testBootWithExtendingModuleWithExistingService(): void } /** - * Test if on Properties::isDebug() === false no Exception is thrown - * and Boostrap::boot() returns false. - * * @test */ - public function testBootWithThrowingModuleAndDebugFalse(): void + public function testBootWithExecutableModule(): void { - $exception = new \Exception("Catch me if you can!"); - - $module = $this->mockModule('id', ExecutableModule::class); - $module->shouldReceive('run')->andThrow($exception); - - $package = Package::new($this->mockProperties('basename', false)); + $moduleId = 'executable-module'; + $module = $this->mockModule($moduleId, ExecutableModule::class); + $module->expects('run')->andReturn(true); - $failedHook = $package->hookName(Package::ACTION_FAILED_BOOT); - Monkey\Actions\expectDone($failedHook)->once()->with($exception); + $package = Package::new($this->mockProperties())->addModule($module); - static::assertFalse($package->boot($module)); - static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + static::assertTrue($package->boot()); + static::assertTrue($package->moduleIs($moduleId, Package::MODULE_ADDED)); + static::assertTrue($package->moduleIs($moduleId, Package::MODULE_EXECUTED)); + static::assertFalse($package->moduleIs($moduleId, Package::MODULE_EXECUTION_FAILED)); } /** - * Test if on Properties::isDebug() === true an Exception is thrown. + * Test, when the ExecutableModule::run() return false, that the state is correctly set. * * @test */ - public function testBootWithThrowingModuleAndDebugTrue(): void + public function testBootWithExecutableModuleFailed(): void { - $exception = new \Exception("Catch me if you can!"); + $moduleId = 'executable-module'; + $module = $this->mockModule($moduleId, ExecutableModule::class); + $module->expects('run')->andReturn(false); - $module = $this->mockModule('id', ExecutableModule::class); - $module->shouldReceive('run')->andThrow($exception); + $package = Package::new($this->mockProperties())->addModule($module); - $package = Package::new($this->mockProperties('basename', true)); - - $failedHook = $package->hookName(Package::ACTION_FAILED_BOOT); - Monkey\Actions\expectDone($failedHook)->once()->with($exception); + static::assertTrue($package->boot()); + static::assertTrue($package->moduleIs($moduleId, Package::MODULE_ADDED)); + static::assertFalse($package->moduleIs($moduleId, Package::MODULE_EXECUTED)); + static::assertTrue($package->moduleIs($moduleId, Package::MODULE_EXECUTION_FAILED)); + } - $this->expectExceptionObject($exception); - $package->boot($module); + /** + * @test + * @runInSeparateProcess + */ + public function testBootPassingModulesEmitDeprecation(): void + { + $module1 = $this->mockModule('module_1', ServiceModule::class); + $module1->allows('services')->andReturn($this->stubServices('service_1')); + + $package = Package::new($this->mockProperties('test', true)); + + $this->convertDeprecationsToExceptions(); + try { + $count = 0; + $package->boot($module1); + } catch (\Throwable $throwable) { + $count++; + $this->assertThrowableMessageMatches($throwable, 'boot().+?deprecated.+?1\.7'); + } finally { + static::assertSame(1, $count); + } } /** * @test */ - public function testBootWithExecutableModule(): void + public function testAddModuleFailsAfterBuild(): void { - $moduleId = 'executable-module'; - $module = $this->mockModule($moduleId, ExecutableModule::class); - $module->shouldReceive('run')->andReturn(true); + $package = Package::new($this->mockProperties('test', true))->build(); - $package = Package::new($this->mockProperties()); + $this->expectExceptionMessageMatches("/add module.+?status.+?at end of building stage/i"); - static::assertTrue($package->boot($module)); - static::assertTrue($package->moduleIs($moduleId, Package::MODULE_ADDED)); - static::assertTrue($package->moduleIs($moduleId, Package::MODULE_EXECUTED)); - static::assertFalse($package->moduleIs($moduleId, Package::MODULE_EXECUTION_FAILED)); + $package->addModule($this->mockModule()); } /** - * Test, when the ExecutableModule::run() return false, that the state is correctly set. - * * @test */ - public function testBootWithExecutableModuleFailed(): void + public function testPropertiesCanBeRetrievedFromContainer(): void { - $moduleId = 'executable-module'; - $module = $this->mockModule($moduleId, ExecutableModule::class); - $module->shouldReceive('run')->andReturn(false); - - $package = Package::new($this->mockProperties()); + $expected = $this->mockProperties(); + $actual = Package::new($expected)->build()->container()->get(Package::PROPERTIES); - static::assertTrue($package->boot($module)); - static::assertTrue($package->moduleIs($moduleId, Package::MODULE_ADDED)); - static::assertFalse($package->moduleIs($moduleId, Package::MODULE_EXECUTED)); - static::assertTrue($package->moduleIs($moduleId, Package::MODULE_EXECUTION_FAILED)); + static::assertSame($expected, $actual); } /** @@ -287,13 +290,13 @@ public function testStatusForMultipleModulesWhenDebug(): void $emptyExtensionsModule = $this->mockModule('empty_extensions', ExtendingModule::class); $servicesModule = $this->mockModule('service', ServiceModule::class); - $servicesModule->shouldReceive('services')->andReturn($this->stubServices('S1', 'S2')); + $servicesModule->expects('services')->andReturn($this->stubServices('S1', 'S2')); $factoriesModule = $this->mockModule('factory', FactoryModule::class); - $factoriesModule->shouldReceive('factories')->andReturn($this->stubServices('F')); + $factoriesModule->expects('factories')->andReturn($this->stubServices('F')); $extendingModule = $this->mockModule('extension', ExtendingModule::class); - $extendingModule->shouldReceive('extensions')->andReturn($this->stubServices('E')); + $extendingModule->expects('extensions')->andReturn($this->stubServices('E')); $multiModule = $this->mockModule( 'multi', @@ -301,11 +304,13 @@ public function testStatusForMultipleModulesWhenDebug(): void ExtendingModule::class, FactoryModule::class ); - $multiModule->shouldReceive('services')->andReturn($this->stubServices('MS1')); - $multiModule->shouldReceive('factories')->andReturn($this->stubServices('MF1', 'MF2')); - $multiModule->shouldReceive('extensions')->andReturn($this->stubServices('ME1', 'ME2')); + $multiModule->expects('services')->andReturn($this->stubServices('MS1')); + $multiModule->expects('factories')->andReturn($this->stubServices('MF1', 'MF2')); + $multiModule->expects('extensions')->andReturn($this->stubServices('ME1', 'ME2')); $package = Package::new($this->mockProperties('name', true)) + ->addModule($emptyModule) + ->addModule($extendingModule) ->addModule($emptyServicesModule) ->addModule($emptyFactoriesModule) ->addModule($emptyExtensionsModule) @@ -313,30 +318,30 @@ public function testStatusForMultipleModulesWhenDebug(): void ->addModule($multiModule) ->addModule($factoriesModule); - static::assertTrue($package->boot($emptyModule, $extendingModule)); + static::assertTrue($package->build()->boot()); $expectedStatus = [ Package::MODULES_ALL => [ - 'empty_services ' . Package::MODULE_NOT_ADDED, - 'empty_factories ' . Package::MODULE_NOT_ADDED, - 'empty_extensions ' . Package::MODULE_NOT_ADDED, - 'service ' . Package::MODULE_REGISTERED . ' (S1, S2)', - 'service ' . Package::MODULE_ADDED, - 'multi ' . Package::MODULE_REGISTERED . ' (MS1)', - 'multi ' . Package::MODULE_REGISTERED_FACTORIES . ' (MF1, MF2)', - 'multi ' . Package::MODULE_EXTENDED . ' (ME1, ME2)', - 'multi ' . Package::MODULE_ADDED, - 'factory ' . Package::MODULE_REGISTERED_FACTORIES . ' (F)', - 'factory ' . Package::MODULE_ADDED, - 'empty ' . Package::MODULE_NOT_ADDED, - 'extension ' . Package::MODULE_EXTENDED . ' (E)', - 'extension ' . Package::MODULE_ADDED, + 'empty not-added', + 'extension extended (E)', + 'extension added', + 'empty_services not-added', + 'empty_factories not-added', + 'empty_extensions not-added', + 'service registered (S1, S2)', + 'service added', + 'multi registered (MS1)', + 'multi registered-factories (MF1, MF2)', + 'multi extended (ME1, ME2)', + 'multi added', + 'factory registered-factories (F)', + 'factory added', ], Package::MODULE_NOT_ADDED => [ + 'empty', 'empty_services', 'empty_factories', 'empty_extensions', - 'empty', ], Package::MODULE_REGISTERED => [ 'service', @@ -347,14 +352,14 @@ public function testStatusForMultipleModulesWhenDebug(): void 'factory', ], Package::MODULE_EXTENDED => [ - 'multi', 'extension', + 'multi', ], Package::MODULE_ADDED => [ + 'extension', 'service', 'multi', 'factory', - 'extension', ], ]; @@ -379,13 +384,13 @@ public function testStatusForMultipleModulesWhenNotDebug(): void $emptyExtensionsModule = $this->mockModule('empty_extensions', ExtendingModule::class); $servicesModule = $this->mockModule('service', ServiceModule::class); - $servicesModule->shouldReceive('services')->andReturn($this->stubServices('S1', 'S2')); + $servicesModule->expects('services')->andReturn($this->stubServices('S1', 'S2')); $factoriesModule = $this->mockModule('factory', FactoryModule::class); - $factoriesModule->shouldReceive('factories')->andReturn($this->stubServices('F')); + $factoriesModule->expects('factories')->andReturn($this->stubServices('F')); $extendingModule = $this->mockModule('extension', ExtendingModule::class); - $extendingModule->shouldReceive('extensions')->andReturn($this->stubServices('E')); + $extendingModule->expects('extensions')->andReturn($this->stubServices('E')); $multiModule = $this->mockModule( 'multi', @@ -393,11 +398,13 @@ public function testStatusForMultipleModulesWhenNotDebug(): void ExtendingModule::class, FactoryModule::class ); - $multiModule->shouldReceive('services')->andReturn($this->stubServices('MS1')); - $multiModule->shouldReceive('factories')->andReturn($this->stubServices('MF1', 'MF2')); - $multiModule->shouldReceive('extensions')->andReturn($this->stubServices('ME1', 'ME2')); + $multiModule->expects('services')->andReturn($this->stubServices('MS1')); + $multiModule->expects('factories')->andReturn($this->stubServices('MF1', 'MF2')); + $multiModule->expects('extensions')->andReturn($this->stubServices('ME1', 'ME2')); $package = Package::new($this->mockProperties('name', false)) + ->addModule($emptyModule) + ->addModule($extendingModule) ->addModule($emptyServicesModule) ->addModule($emptyFactoriesModule) ->addModule($emptyExtensionsModule) @@ -405,10 +412,13 @@ public function testStatusForMultipleModulesWhenNotDebug(): void ->addModule($multiModule) ->addModule($factoriesModule); - static::assertTrue($package->boot($emptyModule, $extendingModule)); + static::assertTrue($package->build()->boot()); $expectedStatus = [ Package::MODULES_ALL => [ + 'empty ' . Package::MODULE_NOT_ADDED, + 'extension ' . Package::MODULE_EXTENDED, + 'extension ' . Package::MODULE_ADDED, 'empty_services ' . Package::MODULE_NOT_ADDED, 'empty_factories ' . Package::MODULE_NOT_ADDED, 'empty_extensions ' . Package::MODULE_NOT_ADDED, @@ -420,15 +430,12 @@ public function testStatusForMultipleModulesWhenNotDebug(): void 'multi ' . Package::MODULE_ADDED, 'factory ' . Package::MODULE_REGISTERED_FACTORIES, 'factory ' . Package::MODULE_ADDED, - 'empty ' . Package::MODULE_NOT_ADDED, - 'extension ' . Package::MODULE_EXTENDED, - 'extension ' . Package::MODULE_ADDED, ], Package::MODULE_NOT_ADDED => [ + 'empty', 'empty_services', 'empty_factories', 'empty_extensions', - 'empty', ], Package::MODULE_REGISTERED => [ 'service', @@ -439,14 +446,14 @@ public function testStatusForMultipleModulesWhenNotDebug(): void 'factory', ], Package::MODULE_EXTENDED => [ - 'multi', 'extension', + 'multi', ], Package::MODULE_ADDED => [ + 'extension', 'service', 'multi', 'factory', - 'extension', ], ]; @@ -466,15 +473,19 @@ public function testStatusForMultipleModulesWhenNotDebug(): void public function testPackageConnection(): void { $module1 = $this->mockModule('module_1', ServiceModule::class); - $module1->shouldReceive('services')->andReturn($this->stubServices('service_1')); + $module1->expects('services')->andReturn($this->stubServices('service_1')); $package1 = Package::new($this->mockProperties('package_1', false)) ->addModule($module1); $module2 = $this->mockModule('module_2', ServiceModule::class); - $module2->shouldReceive('services')->andReturn($this->stubServices('service_2')); + $module2->expects('services')->andReturn($this->stubServices('service_2')); $package2 = Package::new($this->mockProperties('package_2', false)) ->addModule($module2); + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_PACKAGE_CONNECTED)) + ->once() + ->with($package1->name(), Package::STATUS_IDLE, false); + $package1->boot(); $connected = $package2->connect($package1); @@ -491,27 +502,75 @@ public function testPackageConnection(): void * * @test */ - public function testPackageConnectionFailsIfBooted(): void + public function testPackageConnectionFailsIfBootedWithDebugOff(): void { $module1 = $this->mockModule('module_1', ServiceModule::class); - $module1->shouldReceive('services')->andReturn($this->stubServices('service_1')); + $module1->expects('services')->andReturn($this->stubServices('service_1')); $package1 = Package::new($this->mockProperties('package_1', false)) ->addModule($module1); $module2 = $this->mockModule('module_2', ServiceModule::class); - $module2->shouldReceive('services')->andReturn($this->stubServices('service_2')); + $module2->expects('services')->andReturn($this->stubServices('service_2')); $package2 = Package::new($this->mockProperties('package_2', false)) ->addModule($module2); $package1->boot(); $package2->boot(); + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_CONNECTION)) + ->once() + ->with($package1->name(), \Mockery::type(\WP_Error::class)); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + function (\Throwable $throwable): void { + $this->assertThrowableMessageMatches($throwable, 'failed connect.+?booted'); + } + ); + $connected = $package2->connect($package1); static::assertFalse($connected); static::assertSame(['package_1' => false], $package2->connectedPackages()); } + /** + * Test we can not connect services when the package how call connect is booted. + * + * @test + */ + public function testPackageConnectionFailsIfBootedWithDebugOn(): void + { + $module1 = $this->mockModule('module_1', ServiceModule::class); + $module1->expects('services')->andReturn($this->stubServices('service_1')); + $package1 = Package::new($this->mockProperties('package_1', true)) + ->addModule($module1); + + $module2 = $this->mockModule('module_2', ServiceModule::class); + $module2->expects('services')->andReturn($this->stubServices('service_2')); + $package2 = Package::new($this->mockProperties('package_2', true)) + ->addModule($module2); + + $package1->boot(); + $package2->boot(); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_CONNECTION)) + ->once() + ->with($package1->name(), \Mockery::type(\WP_Error::class)); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + function (\Throwable $throwable) { + $this->assertThrowableMessageMatches($throwable, 'failed connect.+?booted'); + } + ); + + $this->expectExceptionMessageMatches('/failed connect.+?booted/i'); + $package2->connect($package1); + } + /** * Test we can connect services even if target package is not booted yet. * @@ -520,12 +579,12 @@ public function testPackageConnectionFailsIfBooted(): void public function testPackageConnectionWithProxyContainer(): void { $module1 = $this->mockModule('module_1', ServiceModule::class); - $module1->shouldReceive('services')->andReturn($this->stubServices('service_1')); + $module1->expects('services')->andReturn($this->stubServices('service_1')); $package1 = Package::new($this->mockProperties('package_1', false)) ->addModule($module1); $module2 = $this->mockModule('module_2', ServiceModule::class); - $module2->shouldReceive('services')->andReturn($this->stubServices('service_2')); + $module2->expects('services')->andReturn($this->stubServices('service_2')); $package2 = Package::new($this->mockProperties('package_2', false)) ->addModule($module2); @@ -557,12 +616,12 @@ public function testPackageConnectionWithProxyContainer(): void public function testPackageConnectionWithProxyContainerFailsIfNoBoot(): void { $module1 = $this->mockModule('module_1', ServiceModule::class); - $module1->shouldReceive('services')->andReturn($this->stubServices('service_1')); + $module1->expects('services')->andReturn($this->stubServices('service_1')); $package1 = Package::new($this->mockProperties('package_1', false)) ->addModule($module1); $module2 = $this->mockModule('module_2', ServiceModule::class); - $module2->shouldReceive('services')->andReturn($this->stubServices('service_2')); + $module2->expects('services')->andReturn($this->stubServices('service_2')); $package2 = Package::new($this->mockProperties('package_2', false)) ->addModule($module2); @@ -583,31 +642,81 @@ public function testPackageConnectionWithProxyContainerFailsIfNoBoot(): void * * @test */ - public function testPackageCanOnlyBeConnectedOnce(): void + public function testPackageCanOnlyBeConnectedOnceDebugOff(): void { $module1 = $this->mockModule('module_1', ServiceModule::class); - $module1->shouldReceive('services')->andReturn($this->stubServices('service_1')); + $module1->expects('services')->andReturn($this->stubServices('service_1')); $package1 = Package::new($this->mockProperties('package_1', false)) ->addModule($module1); $module2 = $this->mockModule('module_2', ServiceModule::class); - $module2->shouldReceive('services')->andReturn($this->stubServices('service_2')); + $module2->expects('services')->andReturn($this->stubServices('service_2')); $package2 = Package::new($this->mockProperties('package_2', false)) ->addModule($module2); - $actionOk = $package2->hookName(Package::ACTION_PACKAGE_CONNECTED); - $actionFailed = $package2->hookName(Package::ACTION_FAILED_CONNECTION); + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_PACKAGE_CONNECTED)) + ->once(); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_CONNECTION)) + ->once() + ->with($package1->name(), \Mockery::type(\WP_Error::class)); - Monkey\Actions\expectDone($actionOk)->once(); - Monkey\Actions\expectDone($actionFailed)->once(); + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + function (\Throwable $throwable): void { + $this->assertThrowableMessageMatches($throwable, 'failed connect.+?already'); + } + ); $connected1 = $package2->connect($package1); + + static::assertTrue($package2->isPackageConnected($package1->name())); + $connected2 = $package2->connect($package1); static::assertTrue($connected1); static::assertFalse($connected2); } + /** + * Test we can connect packages once. + * + * @test + */ + public function testPackageCanOnlyBeConnectedOnceDebugOn(): void + { + $module1 = $this->mockModule('module_1', ServiceModule::class); + $module1->expects('services')->andReturn($this->stubServices('service_1')); + $package1 = Package::new($this->mockProperties('package_1', false)) + ->addModule($module1); + + $module2 = $this->mockModule('module_2', ServiceModule::class); + $module2->expects('services')->andReturn($this->stubServices('service_2')); + $package2 = Package::new($this->mockProperties('package_2', true)) + ->addModule($module2); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_PACKAGE_CONNECTED)) + ->once(); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_CONNECTION)) + ->once() + ->with($package1->name(), \Mockery::type(\WP_Error::class)); + + Monkey\Actions\expectDone($package2->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + function (\Throwable $throwable) { + $this->assertThrowableMessageMatches($throwable, 'failed connect.+?already'); + } + ); + + static::assertTrue($package2->connect($package1)); + + static::expectExceptionMessageMatches('/failed connect.+?already/i'); + $package2->connect($package1); + } + /** * Test we can not connect packages with themselves. * @@ -616,8 +725,8 @@ public function testPackageCanOnlyBeConnectedOnce(): void public function testPackageCanNotBeConnectedWithThemselves(): void { $module1 = $this->mockModule('module_1', ServiceModule::class); - $module1->shouldReceive('services')->andReturn($this->stubServices('service_1')); - $package1 = Package::new($this->mockProperties('package_1', false)) + $module1->expects('services')->andReturn($this->stubServices('service_1')); + $package1 = Package::new($this->mockProperties('package_1', true)) ->addModule($module1); $action = $package1->hookName(Package::ACTION_FAILED_CONNECTION); @@ -625,4 +734,343 @@ public function testPackageCanNotBeConnectedWithThemselves(): void static::assertFalse($package1->connect($package1)); } + + /** + * @test + */ + public function testBuildResolveServices(): void + { + $module = new class() implements ServiceModule, ExtendingModule, ExecutableModule + { + public function id(): string + { + return 'test-module'; + } + + public function services(): array + { + return [ + 'dependency' => function () { + return (object)['x' => 'Works!']; + }, + 'service' => function (ContainerInterface $container) { + $works = $container->get('dependency')->x; + + return new class(['works?' => $works]) extends \ArrayObject {}; + } + ]; + } + + public function extensions(): array + { + return [ + 'service' => function (\ArrayObject $current) { + return new class ($current) { + public $object; + public function __construct(\ArrayObject $object) + { + $this->object = $object; + } + + public function works(): string + { + return $this->object->offsetGet('works?'); + } + }; + } + ]; + } + + public function run(ContainerInterface $container): bool + { + throw new \Error('This should not run!'); + } + }; + + $actual = Package::new($this->mockProperties()) + ->addModule($module) + ->build() + ->container() + ->get('service') + ->works(); + + static::assertSame('Works!', $actual); + } + + /** + * @test + */ + public function testBuildPassingModulesToBoot(): void + { + $module1 = $this->mockModule('module_1', ServiceModule::class); + $module1->expects('services')->andReturn($this->stubServices('service_1')); + + $module2 = $this->mockModule('module_2', ServiceModule::class); + $module2->expects('services')->andReturn($this->stubServices('service_2')); + + $module3 = $this->mockModule('module_3', ServiceModule::class); + $module3->expects('services')->andReturn($this->stubServices('service_3')); + + $package = Package::new($this->mockProperties('test', true)) + ->addModule($module1) + ->addModule($module2) + ->build(); + + $this->ignoreDeprecations(); + $package->boot($module2, $module3); + + $container = $package->container(); + + static::assertSame('service_1', $container->get('service_1')['id']); + static::assertSame('service_2', $container->get('service_2')['id']); + static::assertSame('service_3', $container->get('service_3')['id']); + } + + /** + * @test + */ + public function testBootFailsIfPassingNotAddedModulesAfterContainer(): void + { + $module1 = $this->mockModule('module_1', ServiceModule::class); + $module1->expects('services')->andReturn($this->stubServices('service_1')); + + $module2 = $this->mockModule('module_2', ServiceModule::class); + $module2->expects('services')->andReturn($this->stubServices('service_2')); + + $module3 = $this->mockModule('module_3', ServiceModule::class); + $module3->allows('services')->andReturn($this->stubServices('service_3')); + + $package = Package::new($this->mockProperties('test', true)) + ->addModule($module1) + ->addModule($module2) + ->build(); + + $container = $package->container(); + + static::assertSame('service_1', $container->get('service_1')['id']); + static::assertSame('service_2', $container->get('service_2')['id']); + + $this->expectExceptionMessageMatches('/add module module_3/i'); + $this->ignoreDeprecations(); + $package->boot($module2, $module3); + } + + /** + * When an exception happen inside `Package::boot()` and debug is off, we expect the exception + * to be caught, an "boot failed" action to be failed, and the Package to be in errored status. + * + * @test + */ + public function testFailureFlowWithFailureOnBootDebugModeOff(): void + { + $exception = new \Exception('Test'); + + $module = $this->mockModule('id', ExecutableModule::class); + $module->expects('run')->andThrow($exception); + + $package = Package::new($this->mockProperties())->addModule($module); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BOOT)) + ->once() + ->with($exception); + + static::assertFalse($package->boot()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + + /** + * When an exception happen inside `Package::boot()` and debug is of, we expect it to bubble up. + * + * @test + */ + public function testFailureFlowWithFailureOnBootDebugModeOn(): void + { + $exception = new \Exception('Test'); + + $module = $this->mockModule('id', ExecutableModule::class); + $module->expects('run')->andThrow($exception); + + $package = Package::new($this->mockProperties('basename', true))->addModule($module); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BOOT)) + ->once() + ->with($exception); + + $this->expectExceptionObject($exception); + $package->boot(); + } + + /** + * When multiple calls to `Package::addPackage()` throw an exception, and debug is off, we + * expect none of them to bubble up, and the first to cause the "build failed" action. + * We also expect the Package to be in errored status. + * We expect all other `Package::addPackage()` exceptions to do not fire action hook.@psalm-allow-private-mutation + * We expect Package::build()` to fail without doing anything. Finally, when `Package::boot()` + * is called, we expect the action "boot failed" to be called, and the passed exception to have + * an exception hierarchy with all the thrown exceptions. + * + * @test + */ + public function testFailureFlowWithFailureOnAddModuleDebugModeOff(): void + { + $exception = new \Exception('Test 1'); + + $module1 = $this->mockModule('one', ServiceModule::class); + $module1->expects('services')->andThrow($exception); + + $module2 = $this->mockModule('two', ServiceModule::class); + $module2->expects('services')->never(); + + $package = Package::new($this->mockProperties()); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + static function (\Throwable $throwable) use ($exception, $package): void { + static::assertSame($exception, $throwable); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BOOT)) + ->once() + ->whenHappen( + function (\Throwable $throwable) use ($exception, $package): void { + $this->assertThrowableMessageMatches($throwable, 'boot application'); + $previous = $throwable->getPrevious(); + $this->assertThrowableMessageMatches($previous, 'build package'); + $previous = $previous->getPrevious(); + $this->assertThrowableMessageMatches($previous, 'add module two'); + static::assertSame($exception, $previous->getPrevious()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + static::assertFalse($package->addModule($module1)->addModule($module2)->build()->boot()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + + /** + * The same as the test above, but this time we call `Package::boot()` directly, instead of + * `$package->build()->boot()`, but the expectations are identical. + * + * @test + */ + public function testFailureFlowWithFailureOnAddModuleWithoutBuildDebugModeOff(): void + { + $exception = new \Exception('Test 1'); + + $module1 = $this->mockModule('one', ServiceModule::class); + $module1->expects('services')->andThrow($exception); + + $module2 = $this->mockModule('two', ServiceModule::class); + $module2->expects('services')->never(); + + $package = Package::new($this->mockProperties()); + + $connected = Package::new($this->mockProperties()); + $connected->boot(); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + static function (\Throwable $throwable) use ($exception, $package): void { + static::assertSame($exception, $throwable); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BOOT)) + ->once() + ->whenHappen( + function (\Throwable $throwable) use ($exception, $package): void { + $this->assertThrowableMessageMatches($throwable, 'boot application'); + $previous = $throwable->getPrevious(); + $this->assertThrowableMessageMatches($previous, 'build package'); + $previous = $previous->getPrevious(); + $this->assertThrowableMessageMatches($previous, 'failed connect.+?errored'); + $previous = $previous->getPrevious(); + $this->assertThrowableMessageMatches($previous, 'add module two'); + static::assertSame($exception, $previous->getPrevious()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + $package = $package->addModule($module1)->addModule($module2); + + static::assertFalse($package->connect($connected)); + static::assertFalse($package->boot()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + + /** + * When `Package::build()` throws an exception, and debug is off, we expect it to be caught, the + * "build failed" action to be fired, and the Package to be in errored status. When after that + * `Package::boot()` is called we expect the action "boot failed" to be called passing an + * exception whose "previous" is the exception thrown by `Package::build()`. + * + * @test + */ + public function testFailureFlowWithFailureOnBuildDebugModeOff(): void + { + $exception = new \Exception('Test'); + + $package = Package::new($this->mockProperties()); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_INIT)) + ->once() + ->andThrow($exception); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + static function (\Throwable $throwable) use ($exception, $package): void { + static::assertSame($exception, $throwable); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BOOT)) + ->once() + ->whenHappen( + function (\Throwable $throwable) use ($exception, $package): void { + $this->assertThrowableMessageMatches($throwable, 'boot application'); + static::assertSame($exception, $throwable->getPrevious()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + static::assertFalse($package->build()->boot()); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + + /** + * When `Package::build()` throws an exception, and debug is on, we expect it to bubble up. + * + * @test + */ + public function testFailureFlowWithFailureOnBuildDebugModeOn(): void + { + $exception = new \Exception('Test'); + + $package = Package::new($this->mockProperties('basename', true)); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_INIT)) + ->once() + ->andThrow($exception); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BUILD)) + ->once() + ->whenHappen( + static function (\Throwable $throwable) use ($exception, $package): void { + static::assertSame($exception, $throwable); + static::assertTrue($package->statusIs(Package::STATUS_FAILED)); + } + ); + + Monkey\Actions\expectDone($package->hookName(Package::ACTION_FAILED_BOOT))->never(); + + $this->expectExceptionObject($exception); + $package->build()->boot(); + } } diff --git a/tests/unit/Properties/LibraryPropertiesTest.php b/tests/unit/Properties/LibraryPropertiesTest.php index 647540a..4f2c75f 100644 --- a/tests/unit/Properties/LibraryPropertiesTest.php +++ b/tests/unit/Properties/LibraryPropertiesTest.php @@ -165,9 +165,11 @@ public function testForLibraryAllProperties(): void */ public function testPhpDevRequireParsing(string $requirement, ?string $expected): void { + $which = random_int(1, 10) > 5 ? 'require-dev' : 'require'; + $composerJsonData = [ 'name' => 'inpsyde/some-package_name', - 'require-dev' => [ + $which => [ 'php' => $requirement, ], ]; @@ -268,6 +270,7 @@ public function providePhpRequirements(): array ['dev-src#abcde as 7.0.5-dev || >= 7.1 < 7.2.3', '7.0.5'], // things we don't accept + [' || ', null], ['<= 8', null], ['<8', null], ['dev-master', null], diff --git a/tests/unit/Properties/ThemePropertiesTest.php b/tests/unit/Properties/ThemePropertiesTest.php index 2b6ecae..724d293 100644 --- a/tests/unit/Properties/ThemePropertiesTest.php +++ b/tests/unit/Properties/ThemePropertiesTest.php @@ -105,8 +105,8 @@ public function testChildTheme(): void $themeStub = \Mockery::mock(\WP_Theme::class); + $themeStub->allows('get')->andReturnArg(0)->byDefault(); $themeStub->expects('get')->with('Template')->andReturn($expectedTemplate); - $themeStub->shouldReceive('get')->zeroOrMoreTimes()->andReturnArg(0); $themeStub->expects('get_stylesheet')->andReturn($expectedBaseName); $themeStub->expects('get_stylesheet_directory')->andReturn($expectedBasePath);