From cfbc743846668937c491c668e0c8b354efc62eea Mon Sep 17 00:00:00 2001 From: Tim Chandler Date: Mon, 15 May 2023 04:30:31 +1000 Subject: [PATCH] Initial commit --- .editorconfig | 48 + .gitattributes | 15 + .github/CODE_OF_CONDUCT.md | 132 + .github/CONTRIBUTING.md | 62 + .github/ISSUE_TEMPLATE.md | 46 + .github/workflows/run-tests.yml | 122 + .gitignore | 18 + CHANGELOG.md | 12 + LICENSE.md | 21 + README.md | 582 ++++ composer.json | 76 + config/control.config.php | 75 + infection.json.dist | 22 + phpcs.xml.dist | 30 + phpstan.neon.dist | 7 + phpunit.github-actions.up-to-9.xml.dist | 37 + phpunit.github-actions.xml.dist | 53 + phpunit.xml.dist | 58 + src/CatchType.php | 433 +++ src/Control.php | 424 +++ src/Exceptions/ClarityControlException.php | 12 + .../ClarityControlInitialisationException.php | 33 + .../ClarityControlRuntimeException.php | 19 + src/ServiceProvider.php | 60 + src/Settings.php | 31 + src/Support/ControlTraits/HasCatchTypes.php | 499 ++++ .../ControlTraits/HasGlobalCallbacks.php | 64 + src/Support/Inspector.php | 360 +++ src/Support/InternalSettings.php | 24 + src/Support/Support.php | 34 + .../Integration/MetaCallStackPruning1Test.php | 507 ++++ .../Integration/MetaCallStackPruning2Test.php | 185 ++ tests/LaravelTestCase.php | 29 + tests/PHPUnitTestCase.php | 12 + tests/Support/MethodCall.php | 67 + tests/Support/MethodCalls.php | 118 + tests/Unit/CatchTypeUnitTest.php | 505 ++++ tests/Unit/ControlReportingLevelUnitTest.php | 126 + tests/Unit/ControlUnitTest.php | 2502 +++++++++++++++++ tests/Unit/Exceptions/ExceptionUnitTest.php | 47 + .../Unit/Support/InspectorConfigUnitTest.php | 359 +++ tests/Unit/Support/InspectorUnitTest.php | 645 +++++ tests/Unit/Support/SupportUnitTest.php | 112 + tests/Unit/Traits/HasCatchTypesUnitTest.php | 83 + 44 files changed, 8706 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/workflows/run-tests.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/control.config.php create mode 100644 infection.json.dist create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist create mode 100644 phpunit.github-actions.up-to-9.xml.dist create mode 100644 phpunit.github-actions.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 src/CatchType.php create mode 100644 src/Control.php create mode 100644 src/Exceptions/ClarityControlException.php create mode 100644 src/Exceptions/ClarityControlInitialisationException.php create mode 100644 src/Exceptions/ClarityControlRuntimeException.php create mode 100644 src/ServiceProvider.php create mode 100644 src/Settings.php create mode 100644 src/Support/ControlTraits/HasCatchTypes.php create mode 100644 src/Support/ControlTraits/HasGlobalCallbacks.php create mode 100644 src/Support/Inspector.php create mode 100644 src/Support/InternalSettings.php create mode 100644 src/Support/Support.php create mode 100644 tests/Integration/MetaCallStackPruning1Test.php create mode 100644 tests/Integration/MetaCallStackPruning2Test.php create mode 100644 tests/LaravelTestCase.php create mode 100644 tests/PHPUnitTestCase.php create mode 100644 tests/Support/MethodCall.php create mode 100644 tests/Support/MethodCalls.php create mode 100644 tests/Unit/CatchTypeUnitTest.php create mode 100644 tests/Unit/ControlReportingLevelUnitTest.php create mode 100644 tests/Unit/ControlUnitTest.php create mode 100644 tests/Unit/Exceptions/ExceptionUnitTest.php create mode 100644 tests/Unit/Support/InspectorConfigUnitTest.php create mode 100644 tests/Unit/Support/InspectorUnitTest.php create mode 100644 tests/Unit/Support/SupportUnitTest.php create mode 100644 tests/Unit/Traits/HasCatchTypesUnitTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bc5ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,48 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.js] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.json.dist] +indent_size = 2 + +[*.json5] +indent_size = 2 + +[*.json5.dist] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.neon] +indent_size = 2 + +[*.neon.dist] +indent_size = 2 + +[*.xml] +indent_size = 2 + +[*.xml.dist] +indent_size = 2 + +[*.yml] +indent_size = 2 + +[*.yml.dist] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c356234 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github/ export-ignore +/tests/ export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/infection.json.dist export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpunit.github-actions.up-to-9.xml.dist export-ignore +/phpunit.github-actions.xml.dist export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..80056c3 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +conduct@code-distortion.net. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..cfb8c6c --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +Please read and understand the contribution guide before creating an issue or pull request. + +Contributions will be fully credited. + + + +### Please Note + +- One of the maintainers' goals is to keep the project concise, so not all PRs will be accepted. +- Maintainers will need to maintain new code for its lifetime, so discretion is used when considering a PR for acceptance. +- If you're unsure, feel free to drop us a line first. + + + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code held within. They make the code freely available in the hope that it will be of use to other developers. It would be extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + + + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open source projects are used by many developers, who may have entirely different needs to your own. Think about whether or not your feature is likely to be used by other users of the project. + + + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Please raise an issue to discuss the problem first. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + + + +## Requirements + +- *[PSR-12 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-extended-coding-style-guide.md)* where possible - falling back to [PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) otherwise - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Clearly note any changes, so [Clarity Control's documentation](https://github.com/code-distortion/clarity-control) can be updated. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is to be avoided. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..452edad --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,46 @@ +#### Summary: + + + + + +#### Versions: + +- Clarity Control version: +- Clarity Context version (if used): +- Clarity Logger version (if used): +- Laravel version: +- PHP version: +- OS + version: + + + +#### Detailed Description: + + + + + +#### Current Behaviour: + + + + + +#### How To Reproduce: + + + + + + + +#### Expected Behaviour: + + + + + +#### Additional Information: + + diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..861c64f --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,122 @@ +name: run-tests + +on: + push: +# branches: [ "main" ] + pull_request: +# branches: [ "main" ] + schedule: + - cron: "0 0 * * 0" + +permissions: + contents: read + +jobs: + + all_tests: + + name: "PHP${{ matrix.php }} TB${{ matrix.testbench }} ${{ matrix.os-title }} ${{ matrix.dependency-prefer-title }}" + runs-on: "${{ matrix.os }}" + strategy: + fail-fast: false + matrix: + os: [ "ubuntu-latest", "macos-latest", "windows-latest" ] + php: [ "8.3", "8.2", "8.1", "8.0" ] + testbench: [ "^8.0", "^7.0", "^6.26", "^6.12" ] + dependency-prefer: [ "prefer-stable", "prefer-lowest" ] + include: + - php: "8.3" + phpunit: "^10.1.0" + phpunit-config-file: "phpunit.github-actions.xml.dist" + - php: "8.2" + phpunit: "^10.1.0" + phpunit-config-file: "phpunit.github-actions.xml.dist" + - php: "8.1" + phpunit: "^10.1.0" + phpunit-config-file: "phpunit.github-actions.xml.dist" + - php: "8.0" + phpunit: "^9.3" + phpunit-config-file: "phpunit.github-actions.up-to-9.xml.dist" + + - testbench: "^7.0" + phpunit: "^9.3" + phpunit-config-file: "phpunit.github-actions.up-to-9.xml.dist" + - testbench: "^6.26" + phpunit: "^9.3" + phpunit-config-file: "phpunit.github-actions.up-to-9.xml.dist" + - testbench: "^6.12" + phpunit: "^9.3" + phpunit-config-file: "phpunit.github-actions.up-to-9.xml.dist" + - testbench: "^6.12" + phpunit: "^9.3" + phpunit-config-file: "phpunit.github-actions.up-to-9.xml.dist" + + - os: "ubuntu-latest" + os-title: "ubuntu" + - os: "macos-latest" + os-title: "macos" + - os: "windows-latest" + os-title: "win" + + - dependency-prefer: "prefer-stable" + dependency-prefer-title: "stable" + - dependency-prefer: "prefer-lowest" + dependency-prefer-title: "lowest" + exclude: + - testbench: "^8.0" + php: "8.0" + - testbench: "^6.26" # Laravel 8 for higher versions of php + php: "8.0" + - testbench: "^6.12" # Laravel 8 for lower versions of php + php: "8.3" + - testbench: "^6.12" # Laravel 8 for lower versions of php + php: "8.2" + - testbench: "^6.12" # Laravel 8 for lower versions of php + php: "8.1" + + steps: + - name: "Checkout code" + uses: "actions/checkout@v4" + + - name: "Validate composer.json and composer.lock" + run: "composer validate --strict" + + - name: "Setup PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php }}" + extensions: fileinfo # required by league/flysystem on Windows + ini-values: "error_reporting=E_ALL" + coverage: none + env: + COMPOSER_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + # find composer's cache directory - so we know which directory to cache in the next step + - name: "Find composer's cache directory" + id: "composer-cache" + shell: bash # make sure this step works on Windows - see https://github.com/actions/runner/issues/2224#issuecomment-1289533957 + run: | + echo "composer_cache_dir=$(composer config cache-files-dir)">> "$GITHUB_OUTPUT" + + - name: "Cache composer's cache directory" + uses: "actions/cache@v3" + with: + path: "${{ steps.composer-cache.outputs.composer_cache_dir }}" + key: "[${{ matrix.os }}][php-${{ matrix.php }}][testbench-${{ matrix.testbench }}][${{ matrix.dependency-prefer }}][composer.json-${{ hashFiles('composer.json') }}]" + + - name: "Install dependencies" + uses: "nick-fields/retry@v2" + with: + timeout_minutes: 5 + max_attempts: 5 + shell: bash # make sure "^" characters are interpreted properly on Windows (e.g. in "^5.0") + command: | + composer remove "infection/infection" --dev --no-interaction --no-update + composer remove "phpstan/phpstan" --dev --no-interaction --no-update + composer remove "squizlabs/php_codesniffer" --dev --no-interaction --no-update + composer require "orchestra/testbench:${{ matrix.testbench }}" --dev --no-interaction --no-update + composer require "phpunit/phpunit:${{ matrix.phpunit }}" --dev --no-interaction --no-update + composer update --${{ matrix.dependency-prefer }} --prefer-dist --no-interaction --optimize-autoloader --no-progress + + - name: "Execute tests" + run: vendor/bin/phpunit --configuration=${{ matrix.phpunit-config-file }} --no-coverage --stop-on-error --stop-on-failure diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7ca0c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.idea/ +.phpunit/ +.phpunit.cache/ +build/ +infection/ +phpunit/ +phpunit.cache/ +vendor/ +vendor.*/ +.phpunit.result.cache +composer.lock +infection.json +phpcs.xml +phpstan.neon +phpunit.xml +tests/Unit/ManualTest.php +todo.txt +update-steps.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f90d3d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to `code-distortion/clarity-control` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [0.1.0] - 2023-12-31 + +### Added +- Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7d3f620 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tim Chandler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14b9221 --- /dev/null +++ b/README.md @@ -0,0 +1,582 @@ +# Clarity Control - Handle Your Exceptions + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/code-distortion/clarity-control.svg?style=flat-square)](https://packagist.org/packages/code-distortion/clarity-control) +![PHP Version](https://img.shields.io/badge/PHP-8.0%20to%208.3-blue?style=flat-square) +![Laravel](https://img.shields.io/badge/laravel-8%20to%2010-blue?style=flat-square) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/code-distortion/clarity-control/run-tests.yml?branch=master&style=flat-square)](https://github.com/code-distortion/clarity-control/actions) +[![Buy The World a Tree](https://img.shields.io/badge/treeware-%F0%9F%8C%B3-lightgreen?style=flat-square)](https://plant.treeware.earth/code-distortion/clarity-control) +[![Contributor Covenant](https://img.shields.io/badge/contributor%20covenant-v2.1%20adopted-ff69b4.svg?style=flat-square)](.github/CODE_OF_CONDUCT.md) + +***code-distortion/clarity-control*** is a Laravel package that lets you catch and log exceptions with a fluent interface. + +``` php +// run $callable - will catch + report() any exceptions +Control::run($callable); + +// do the same, but with extra optional configuration +Control::prepare($callable) + ->channel('slack') + ->level(Settings::REPORTING_LEVEL_WARNING) + ->debug() … ->emergency() + ->default('some-value') + ->catch(DivisionByZeroError::class) + ->match('Undefined variable $a') + ->matchRegex('/^Undefined variable /') + ->known('https://company.atlassian.net/browse/ISSUE-1234') + ->report() or ->dontReport() + ->rethrow() or ->dontRethrow() or ->rethrow($callable) + ->callback($callable) + ->finally($callable) + ->execute(); +``` + + + +
+ + + +## Clarity Suite + +Clarity Control is a part of the ***Clarity Suite***, designed to let you manage exceptions more easily: +- [Clarity Context](https://github.com/code-distortion/clarity-context) - Understand Your Exceptions +- [Clarity Logger](https://github.com/code-distortion/clarity-logger) - Useful Exception Logs +- **Clarity Control** - Handle Your Exceptions + + + +
+ + + +## Table of Contents + +- [Installation](#installation) + - [Config File](#config-file) +- [Catching Exceptions](#catching-exceptions) +- [Configuring The Way Exceptions Are Caught](#configuring-the-way-exceptions-are-caught) + - [Note About Logging](#note-about-logging) +- [Log Channel](#log-channel) +- [Log Level](#log-level) +- [Default Return Value](#default-return-value) +- [Catching Selectively](#catching-selectively) +- [Recording "Known" Issues](#recording-known-issues) +- [Disabling Logging](#disabling-logging) +- [Re-throwing Exceptions](#re-throwing-exceptions) +- [Suppressing Exceptions](#suppressing-exceptions) +- [Callbacks](#callbacks) + - [Using The Context Object in Callbacks](#using-the-context-object-in-callbacks) + - [Suppressing Exceptions On The Fly](#suppressing-exceptions-on-the-fly) + - [Global Callbacks](#global-callbacks) +- [Advanced Catching](#advanced-catching) +- [Retrieving Exceptions](#retrieving-exceptions) + + + +## Installation + +Install the package via composer: + +``` bash +composer require code-distortion/clarity-control +``` + + + +### Config File + +Use the following command if you would like to publish the `config/code_distortion.clarity_control.php` config file: + +``` bash +php artisan vendor:publish --provider="CodeDistortion\ClarityControl\ServiceProvider" --tag="config" +``` + + + +## Catching Exceptions + +Clarity Control deals with flow control. Which is to say, it provides ways to catch and manage exceptions. + +Whilst you can still use [try / catch statements](https://www.php.net/manual/en/language.exceptions.php) of course, you can write them this way instead: + +``` php +use CodeDistortion\ClarityControl\Control; + +Control::run($callable); + +// is equivalent to: +try { + $callable(); // … do something +} catch (Exception $e) { + report($e); +} +``` + +Control will run the *callable* passed to `Control::run(…)`. If an exception occurs, it will be caught and reported using Laravel's `report()` helper. + +The code following it will continue to run afterwards. + +> ***Tip:*** [Laravel's *dependency injection*](https://laravel.com/docs/10.x/container#when-to-use-the-container) system is used to run your callable. Just type-hint your parameters and they'll be resolved for you. + + + + + +## Configuring The Way Exceptions Are Caught + +Another way to write this is to call `Control::prepare($callable)`, and then `->execute()` afterwards. + +``` php +use CodeDistortion\ClarityControl\Control; + +Control::prepare($callable)->execute(); +// is equivalent to: +Control::run($callable); +``` + +These are the same, except that when using `Control::prepare($callable)` and `->execute()`, there's an opportunity to set some configuration values in-between… + +Here is the list of the methods you can use to configure the way Control catches exceptions (they're explained in more detail below): + +``` php +use CodeDistortion\ClarityControl\Control; +use CodeDistortion\ClarityControl\Settings; + +Control::prepare($callable) + ->channel('slack') + ->level(Settings::REPORTING_LEVEL_WARNING) + ->debug() … ->emergency() + ->default('default') + ->catch(DivisionByZeroError::class) + ->match('Undefined variable $a') + ->matchRegex('/^Undefined variable /') + ->known('https://company.atlassian.net/browse/ISSUE-1234') + ->report() or ->dontReport() + ->rethrow() or ->dontRethrow() or ->rethrow($callable) + ->callback($callable) + ->finally($callable) + ->execute(); +``` + + + +### Note About Logging + +This package uses [Clarity Context](https://github.com/code-distortion/clarity-context#logging-exceptions), and some settings require a logger that's also aware of *Clarity Context* (such as [Clarity Logger](https://github.com/code-distortion/clarity-logger)) to work. Specifically, the *channel*, *log level*, and *"known" issue* settings won't appear anywhere otherwise. + +These details are added to the `Context` object that *Clarity Context* produces when an exception occurs. And it's up to the logger to use its values. + +See [Clarity Context](https://github.com/code-distortion/clarity-context#logging-exceptions) for more information about this `Context` object. + +See [Clarity Logger](https://github.com/code-distortion/clarity-logger) for a logger that understands the `Context` object. + + + +## Log Channel + +You can specify which Laravel log-channel you'd like to log to. The possible values come from your projects' `config/logging.php` file. + +``` php +Control::prepare($callable)->channel('slack')->execute(); +``` + +You can specify more than one if you'd like. + +``` php +Control::prepare($callable)->channel(['stack', 'slack'])->execute(); +``` + +> ***Note:*** This setting requires a logging tool that's aware of Clarity. See the [Note About Logging](#note-about-logging) for more information. + +> See [Laravel's documentation about logging](https://laravel.com/docs/10.x/logging#available-channel-drivers) for more information about Log Channels. + + + +## Log Level + +You can specify the reporting level you'd like to use when logging. + +``` php +use CodeDistortion\ClarityControl\Settings; + +Control::prepare($callable)->debug()->execute(); +Control::prepare($callable)->info()->execute(); +Control::prepare($callable)->notice()->execute(); +Control::prepare($callable)->warning()->execute(); +Control::prepare($callable)->error()->execute(); +Control::prepare($callable)->critical()->execute(); +Control::prepare($callable)->alert()->execute(); +Control::prepare($callable)->emergency()->execute(); +// or +Control::prepare($callable)->level(Settings::REPORTING_LEVEL_WARNING)->execute(); // etc +``` + +> ***Note:*** This setting requires a logging tool that's aware of Clarity. See the [Note About Logging](#note-about-logging) for more information. + +> See [Laravel's documentation about logging](https://laravel.com/docs/10.x/logging#writing-log-messages) for more information about Log Levels. + + + +## Default Return Value + +You can specify the default value to return when an exception occurs by passing a second parameter to `Control::run()` or `Control::prepare()`. + +``` php +$result = Control::run($callable, $default); +// or +$result = Control::prepare($callable, $default)->execute(); +``` + +You can also call `->default()` after calling `Control::prepare()` + +``` php +$result = Control::prepare($callable)->default($default)->execute(); +``` + +> ***Tip:*** If the default value is *callable*, Control will run it (when needed) to resolve the value. + + + +## Catching Selectively + +You can choose to only catch certain types of exceptions. Other exceptions will ignored. + +``` php +use DivisionByZeroError; + +Control::prepare($callable) + ->catch(DivisionByZeroError::class) // only catch this type of exception + ->execute(); +``` + +``` php +Control::prepare($callable) + ->match('Undefined variable $a') // exact string match of $e->getMessage() + ->matchRegex('/^Undefined variable /') // regex string match of $e->getMessage() + ->execute(); +``` + +You can specify multiple exception classes, match-strings or regexes. + +When you specify `match()` and `matchRegex()`, only one of them needs to match the exception message. + + + +## Recording "Known" Issues + +If you use an issue management system like Jira, you can make a note of the issue/task the exception relates to: + +``` php +Control::prepare($callable) + ->known('https://company.atlassian.net/browse/ISSUE-1234') + ->execute(); +``` + +This gives you an opportunity to label exceptions while the fix is being worked on. + +> ***Note:*** This setting requires a logging tool that's aware of Clarity. See the [Note About Logging](#note-about-logging) for more information. + + + +## Disabling Logging + +You can disable the reporting of exceptions once caught. This will stop `report()` from being triggered. + +``` php +Control::prepare($callable)->dontReport()->execute(); +// or +Control::prepare($callable)->report(false)->execute(); +``` + + + +## Re-throwing Exceptions + +If you'd like caught exceptions to be detected and reported, but *re-thrown* again afterwards, you can tell Control to rethrow them: + +``` php +Control::prepare($callable)->rethrow()->execute(); +``` + +If you'd like to rethrow a different exception, you can pass a closure to make the decision. It must return a Throwable / Exception, or true / false. + +``` php +$closure = fn(Throwable $e) => new MyException('Something happened', 0, $e); +Control::prepare($callable)->rethrow($closure)->execute(); +``` + + + +## Suppressing Exceptions + +If you'd like to stop exceptions from being reported *and* rethrown once caught, you can suppress them altogether. + +``` php +Control::prepare($callable)->suppress()->execute(); +// is equivalent to: +Control::prepare($callable)->dontReport()->dontRethrow()->execute(); +``` + + + +## Callbacks + +You can add a custom callback to be run when an exception is caught. This can be used to either *do something* when an exception occurs, or to decide if the [the exception should be "suppressed"](#suppressing-exceptions). + +You can add multiple callbacks if you like. + +``` php +use CodeDistortion\ClarityControl\Context; +use Illuminate\Http\Request; +use Throwable; + +$callback = fn(Throwable $e, Context $context, Request $request) => …; // do something + +Control::prepare($callable)->callback($callback)->execute(); +``` + +> ***Tip:*** [Laravel's *dependency injection*](https://laravel.com/docs/10.x/container#when-to-use-the-container) is used to run your callback. Just type-hint your parameters, like in the example above. +> +> Extra parameters you can use are: +> - The exception: when the parameter is named `$e` or `$exception` +> - The `Context` object: when type-hinted with `CodeDistortion\ClarityContext\Context` + + + +### Using The Context Object in Callbacks + +When you type-hint a callback parameter with `CodeDistortion\ClarityContext\Context`, you'll receive the `Context` object populated with details about the exception. + +This is the *same* Context object from the [Clarity Context](https://github.com/code-distortion/clarity-context) package, and is designed to be used in `app/Exceptions/Handler.php` when reporting an exception. See Clarity Context's [documentation for more information](https://github.com/code-distortion/clarity-context#logging-exceptions). + +As well as reading values from the Context object, you can update some of its values inside your callback. This lets you alter what happens on-the-fly. + +``` php +use CodeDistortion\ClarityControl\Context; +use CodeDistortion\ClarityControl\Settings; + +$callback = function (Context $context) { + // the exception that occurred + $context->getException(); + // manage the log channels + $context->getChannels(); + $context->setChannels(['slack']); + // manage the log reporting level + $context->getLevel(); + $context->setLevel(Settings::REPORTING_LEVEL_WARNING); + $context->debug() … $context->emergency(); + // manage the default return value + $context->getDefault(); + $context->setDefault('default'); + // manage the request's trace identifiers + $context->getTraceIdentifiers(); + $context->setTraceIdentifiers([$traceId]); + // manage the known issues + $context->hasKnown(); + $context->getKnown(); + $context->setKnown(['https://company.atlassian.net/browse/ISSUE-1234']); + // manage the report setting + $context->getReport(); + $context->setReport(true/false); + $context->dontReport(); + // manage the rethrow setting + $context->getRethrow(); + $context->setRethrow(true/false); + $context->setRethrow($exception); // a new exception to rethrow + $context->setRethrow($callable); // a closure that decides which exception to rethrow + $context->dontRethrow(); + // turn both report and rethrow off + $context->suppress(); +}; +``` + + + +### Suppressing Exceptions On The Fly + +You can suppress an exception by calling `$context->suppress()` inside your callback. + +This will also happen if your callback sets `$context->setReport(false)` *and* `$context->setRethrow(false)`. + +> ***Tip:*** Callbacks are run in the order they were specified. Subsequent callbacks won't be called when the exception is suppressed. + +``` php +use CodeDistortion\ClarityControl\Context; +use Illuminate\Http\Request; + +$callback = function (Context $context, Request $request) { + + // suppress the exception when the user-agent is 'test-agent' + if ($request->userAgent == 'test-agent') { + $context->suppress() + // or + $context->setReport(false)->setRethrow(false); + } +}; + +Control::prepare($callable)->callback($callback)->execute(); +``` + + + +### Global Callbacks + +You can tell Control to run a "global" callback whenever it catches an exception. You can add as many as you need. + +These callbacks are run *before* the regular (non-global) callbacks. + +``` php +Control::globalCallback($callable); +``` + +A good place to set one up would be in a service provider. See Laravel's documentation for [more information about service providers](https://laravel.com/docs/10.x/providers#main-content). + +``` php +namespace App\Providers; + +use Illuminate\Support\ServiceProvider; +use CodeDistortion\ClarityControl\Control; + +class MyServiceProvider extends ServiceProvider +{ + public function boot() + { + $callback = function () { … }; // do something + Control::globalCallback($callback); // <<< + } +} +``` + + + +## Finally + +You can specify a callable to run after the execution of the main `$callable` by passing a third parameter to `Control::run()` or `Control::prepare()`. + +``` php +$finally = fn() => …; // do something + +Control::run($callable, 'default', $finally); +// or +Control::prepare($callable, 'default', $finally)->execute(); +``` + +You can also call `->finally()` after calling `Control::prepare()` + +``` php +Control::prepare($callable)->finally($finally)->execute(); +``` + +> ***Tip:*** [Laravel's *dependency injection*](https://laravel.com/docs/10.x/container#when-to-use-the-container) system is used to run your callable. Just type-hint your parameters and they'll be resolved for you. + + + +## Advanced Catching + +You can choose to do different things when different exceptions are caught. + +To do this, configure a `CodeDistortion\ClarityControl\CatchType` object, and pass *that* to `$clarity->catch()` instead of +passing the exception class *string*. + +`CatchType` objects can be customised with the same settings as the `Control` object. They're all optional, and can be called in any order. + +``` php +use CodeDistortion\ClarityControl\CatchType; +use CodeDistortion\ClarityControl\Settings; + +$catchType1 = CatchType::channel('slack') + ->level(Settings::REPORTING_LEVEL_WARNING) + ->debug() … ->emergency() + ->default('default') + ->catch(DivisionByZeroError::class) + ->match('Undefined variable $a') + ->matchRegex('/^Undefined variable /') + ->known('https://company.atlassian.net/browse/ISSUE-1234') + ->report() or ->dontReport() + ->rethrow() or ->dontRethrow() or ->rethrow($callable) + ->suppress() + ->callback($callable) + ->finally($callable); +$catchType2 = …; + +Control::prepare($callable) + ->catch($catchType1) + ->catch($catchType2) + ->execute(); +``` + +CatchTypes are checked in the order they were specified. The first one that matches the exception is used. + + + +## Retrieving Exceptions + +If you'd like to obtain the exception, you can call `getException()` and pass a variable by reference. When an exception occurs, it will contain the exception afterwards. Otherwise it's set to *null*. + +The exception will be set, even if the exception was [suppressed](#suppressing-exceptions). + +``` php +Control::prepare($callable)->getException($e)->execute(); + +dump($e); // will contain the exception, or null +``` + + + +
+ + + +## Testing This Package + +- Clone this package: `git clone https://github.com/code-distortion/clarity-control.git .` +- Run `composer install` to install dependencies +- Run the tests: `composer test` + + + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + + + +### SemVer + +This library uses [SemVer 2.0.0](https://semver.org/) versioning. This means that changes to `X` indicate a breaking change: `0.0.X`, `0.X.y`, `X.y.z`. When this library changes to version 1.0.0, 2.0.0 and so forth, it doesn't indicate that it's necessarily a notable release, it simply indicates that the changes were breaking. + + + +## Treeware + +This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/code-distortion/clarity-control) to thank us for our work. By contributing to the Treeware forest you’ll be creating employment for local families and restoring wildlife habitats. + + + +## Contributing + +Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. + + + +### Code of Conduct + +Please see [CODE_OF_CONDUCT](.github/CODE_OF_CONDUCT.md) for details. + + + +### Security + +If you discover any security related issues, please email tim@code-distortion.net instead of using the issue tracker. + + + +## Credits + +- [Tim Chandler](https://github.com/code-distortion) + + + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8e4d5d7 --- /dev/null +++ b/composer.json @@ -0,0 +1,76 @@ +{ + "name": "code-distortion/clarity-control", + "description": "A Laravel package to catch and log exceptions with a fluent interface", + "keywords": [ + "laravel", + "error", + "exception", + "catch", + "log", + "report", + "context" + ], + "homepage": "https://github.com/code-distortion/clarity-control", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Tim Chandler", + "email": "tim@code-distortion.net", + "role": "Developer" + } + ], + "require": { + "php": "8.0.* | 8.1.* | 8.2.* | 8.3.*", + "code-distortion/clarity-context": "^0.1.0", + "code-distortion/staticall": "^0.1.0" + }, + "require-dev": { + "infection/infection": "^0.10 | ^0.11 | ^0.12 | ^0.13 | ^0.14 | ^0.15 | ^0.16 | ^0.17 | ^0.18 | ^0.19 | ^0.20 | ^0.21 | ^0.22 | ^0.23 | ^0.24 | ^0.25 | ^0.26 | ^0.27", + "orchestra/testbench": "^6.12 | ^7.0 | ^8.0", + "phpstan/phpstan": "^0.9 | ^0.10 | ^0.11 | ^0.12 | ^1.0", + "phpunit/phpunit": "~4.8 | ^5.0 | ^6.0 | ^7.0 | ^8.4 | ^9.0 | ^10.0", + "squizlabs/php_codesniffer": "^3.8.0" + }, + "autoload": { + "psr-4": { + "CodeDistortion\\ClarityControl\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "CodeDistortion\\ClarityControl\\Tests\\": "tests" + } + }, + "scripts": { + "infection": "vendor/bin/infection --threads=max --show-mutations --test-framework-options=\"--exclude-group=skip\"", + "phpcbf": "vendor/bin/phpcbf", + "phpcs": "vendor/bin/phpcs", + "phpstan": "vendor/bin/phpstan.phar analyse --level=max", + "test": "vendor/bin/phpunit" + }, + "scripts-descriptions": { + "infection": "Run Infection tests", + "phpcbf": "Run PHP Code Beautifier and Fixer against your application", + "phpcs": "Run PHP CodeSniffer against your application", + "phpstan": "Run PHPStan static analysis against your application", + "test": "Run PHPUnit tests" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "CodeDistortion\\ClarityControl\\ServiceProvider" + ] + } + }, + "suggest": { + "code-distortion/clarity-context": "Understand Your Exceptions. Part of the Clarity Suite", + "code-distortion/clarity-logger": "Useful Exception Logs. Part of the Clarity Suite" + } +} diff --git a/config/control.config.php b/config/control.config.php new file mode 100644 index 0000000..7b6929c --- /dev/null +++ b/config/control.config.php @@ -0,0 +1,75 @@ +report(true/false) or ->dontReport(). + | + | boolean + | + */ + + 'report' => env('CLARITY_CONTROL__REPORT', true), + + /* + |-------------------------------------------------------------------------- + | Channels + |-------------------------------------------------------------------------- + | + | The channels picked when reporting exceptions. Will fall back to + | Laravel's default. Can also be chosen at call-time. e.g. + | Control::prepare(..)->channel('xxx'). + | + | Control can associate urls to exceptions (making them "known"). + | + | Note: Used in conjunction with code-distortion/clarity-logger. + | + | More info: + | https://github.com/code-distortion/clarity-control#log-channel + | https://github.com/code-distortion/clarity-control#recording-known-issues + | https://laravel.com/docs/10.x/logging#available-channel-drivers + | + | string / string[] / null + | (can be a string of comma-separated values) + | + */ + + 'channels' => [ + 'when_known' => env('CLARITY_CONTROL__CHANNELS_WHEN_KNOWN'), + 'when_not_known' => env('CLARITY_CONTROL__CHANNELS_WHEN_NOT_KNOWN'), + ], + + /* + |-------------------------------------------------------------------------- + | Reporting Level + |-------------------------------------------------------------------------- + | + | The log reporting levels picked when reporting exceptions. Can also be + | chosen at call-time. e.g. Control::prepare(..)->level('xxx'). + | + | Control can associate urls to exceptions (making them "known"). + | + | Note: Used in conjunction with code-distortion/clarity-logger. + | + | More info: + | https://github.com/code-distortion/clarity-control#log-level + | https://github.com/code-distortion/clarity-control#recording-known-issues + | https://laravel.com/docs/10.x/logging#writing-log-messages + | + | string / null + | + */ + + 'level' => [ + 'when_known' => env('CLARITY_CONTROL__LEVEL_WHEN_KNOWN', Settings::REPORTING_LEVEL_ERROR), + 'when_not_known' => env('CLARITY_CONTROL__LEVEL_WHEN_NOT_KNOWN', Settings::REPORTING_LEVEL_ERROR), + ], + +]; diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..ab322be --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,22 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": [ + "src" + ], + "excludes": [ + "src/ServiceProvider.php" + ] + }, + "timeout": 300, + "logs": { + "text": "infection/infection.log", + "html": "infection/infection.html", + "summary": "infection/summary.log", + "json": "infection/infection-log.json", + "perMutator": "infection/per-mutator.md", + }, + "mutators": { + "@default": true + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..55740e1 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + /.git/* + /.github/* + /.idea/* + /.phpunit/* + /.phpunit.cache/* + /build/* + /infection/* + /phpunit/* + /phpunit.cache/* + /vendor/* + /vendor.*/* + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..537f123 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + paths: + - src/ + - tests/ + level: max + parallel: + processTimeout: 300.0 diff --git a/phpunit.github-actions.up-to-9.xml.dist b/phpunit.github-actions.up-to-9.xml.dist new file mode 100644 index 0000000..e33255b --- /dev/null +++ b/phpunit.github-actions.up-to-9.xml.dist @@ -0,0 +1,37 @@ + + + + + ./tests/Unit + + + ./tests/Integration + + + diff --git a/phpunit.github-actions.xml.dist b/phpunit.github-actions.xml.dist new file mode 100644 index 0000000..8e32cc4 --- /dev/null +++ b/phpunit.github-actions.xml.dist @@ -0,0 +1,53 @@ + + + + + ./tests/Unit + + + ./tests/Integration + + + + + ./src + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..17748ce --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,58 @@ + + + + + ./tests/Unit + + + ./tests/Integration + + + + + + + + + + ./src + + + diff --git a/src/CatchType.php b/src/CatchType.php new file mode 100644 index 0000000..22092ee --- /dev/null +++ b/src/CatchType.php @@ -0,0 +1,433 @@ +exceptionClasses, func_get_args()); + $this->exceptionClasses = $exceptionClasses; + + return $this; + } + + + + /** + * Specify string/s the exception message must match. + * + * @param string|string[] $match The string/s the exception message needs to match. + * @param string|string[] ...$match2 The string/s the exception message needs to match. + * @return $this + */ + private function callMatch(string|array $match, string|array ...$match2): self + { + /** @var string[] $matchStrings */ + $matchStrings = Support::normaliseArgs($this->matchStrings, func_get_args()); + $this->matchStrings = $matchStrings; + + return $this; + } + + + + /** + * Specify regex string/s the exception message must match. + * + * @param string|string[] $match The regex string/s the exception message needs to match. + * @param string|string[] ...$match2 The regex string/s the exception message needs to match. + * @return $this + */ + private function callMatchRegex(string|array $match, string|array ...$match2): self + { + /** @var string[] $matchRegexes */ + $matchRegexes = Support::normaliseArgs($this->matchRegexes, func_get_args()); + $this->matchRegexes = $matchRegexes; + + return $this; + } + + + + /** + * Specify a callback to run when an exception occurs. + * + * @param callable $callback The callback to run. + * @return $this + */ + private function callCallback(callable $callback): self + { + $this->callbacks([$callback]); + + return $this; + } + + /** + * Specify callbacks to run when an exception occurs. + * + * @param callable|callable[] $callback The callback/s to run. + * @param callable|callable[] ...$callback2 The callback/s to run. + * @return $this + */ + private function callCallbacks(callable|array $callback, callable|array ...$callback2): self + { + /** @var callable[] $callbacks */ + $callbacks = Support::normaliseArgs($this->callbacks, func_get_args()); + $this->callbacks = $callbacks; + + return $this; + } + + + + /** + * Specify issue/s that the exception is known to belong to. + * + * @param string|string[] $known The issue/s this exception is known to belong to. + * @param string|string[] ...$known2 The issue/s this exception is known to belong to. + * @return $this + */ + private function callKnown(string|array $known, string|array ...$known2): self + { + /** @var string[] $known */ + $known = Support::normaliseArgs($this->known, func_get_args()); + $this->known = $known; + + return $this; + } + + + + /** + * Specify a channel to log to. + * + * @param string $channel The channel to log to. + * @return $this + */ + private function callChannel(string $channel): self + { + $this->channels([$channel]); + + return $this; + } + + /** + * Specify channels to log to. + * + * @param string|string[] $channel The channel/s to log to. + * @param string|string[] ...$channel2 The channel/s to log to. + * @return $this + */ + private function callChannels(string|array $channel, string|array ...$channel2): self + { + /** @var string[] $channels */ + $channels = Support::normaliseArgs($this->channels, func_get_args()); + $this->channels = $channels; + + return $this; + } + + + + /** + * Specify the log reporting level. + * + * @param string $level The log-level to use. + * @return $this + * @throws ClarityControlInitialisationException When an invalid level is specified. + */ + private function callLevel(string $level): self + { + if (!in_array($level, Settings::LOG_LEVELS)) { + throw ClarityControlInitialisationException::levelNotAllowed($level); + } + + $this->level = $level; + + return $this; + } + + /** + * Set the log reporting level to "debug". + * + * @return $this + */ + private function callDebug(): self + { + $this->level = Settings::REPORTING_LEVEL_DEBUG; + + return $this; + } + + /** + * Set the log reporting level to "info". + * + * @return $this + */ + private function callInfo(): self + { + $this->level = Settings::REPORTING_LEVEL_INFO; + + return $this; + } + + /** + * Set the log reporting level to "notice". + * + * @return $this + */ + private function callNotice(): self + { + $this->level = Settings::REPORTING_LEVEL_NOTICE; + + return $this; + } + + /** + * Set the log reporting level to "warning". + * + * @return $this + */ + private function callWarning(): self + { + $this->level = Settings::REPORTING_LEVEL_WARNING; + + return $this; + } + + /** + * Set the log reporting level to "error". + * + * @return $this + */ + private function callError(): self + { + $this->level = Settings::REPORTING_LEVEL_ERROR; + + return $this; + } + + /** + * Set the log reporting level to "critical". + * + * @return $this + */ + private function callCritical(): self + { + $this->level = Settings::REPORTING_LEVEL_CRITICAL; + + return $this; + } + + /** + * Set the log reporting level to "alert". + * + * @return $this + */ + private function callAlert(): self + { + $this->level = Settings::REPORTING_LEVEL_ALERT; + + return $this; + } + + /** + * Set the log reporting level to "emergency". + * + * @return $this + */ + private function callEmergency(): self + { + $this->level = Settings::REPORTING_LEVEL_EMERGENCY; + + return $this; + } + + + + /** + * Specify that exceptions should be reported. + * + * @param boolean $report Whether to report exceptions or not. + * @return $this + */ + private function callReport(bool $report = true): self + { + $this->report = $report; + + return $this; + } + + /** + * Specify that exceptions should not be reported. + * + * @return $this + */ + private function callDontReport(): self + { + $this->report = false; + + return $this; + } + + + + /** + * Specify whether caught exceptions should be re-thrown, or a callable to make the decision. + * + * @param boolean|callable $rethrow Whether to rethrow exceptions or not, or a callable to make the decision. + * @return $this + */ + private function callRethrow(bool|callable $rethrow = true): self + { + $this->rethrow = $rethrow; + + return $this; + } + + /** + * Specify that caught exceptions should not be re-thrown. + * + * @return $this + */ + private function callDontRethrow(): self + { + $this->rethrow = false; + + return $this; + } + + + + /** + * Suppress any exceptions - don't report and don't rethrow them. + * + * @return $this + */ + private function callSuppress(): self + { + $this->report = false; + $this->rethrow = false; + + return $this; + } + + + + /** + * Specify the default value to return when an exception occurs. + * + * @param mixed $default The default value to use. + * @return $this + */ + private function callDefault(mixed $default): self + { + $this->default = $default; + $this->defaultIsSet = true; + + return $this; + } + + + + /** + * Specify a callable to run after the main callable (whether an exception occurred or not). + * + * @param callable|null $finally The callable to run. + * + * @return $this + */ + private function callFinally(?callable $finally): self + { + $this->finally =& $finally; + + return $this; + } +} diff --git a/src/Control.php b/src/Control.php new file mode 100644 index 0000000..262dc29 --- /dev/null +++ b/src/Control.php @@ -0,0 +1,424 @@ +run(..) (which runs execute() straight + * away) or not. + * @param mixed $default The default value to return if an exception occurs. + * @param boolean $defaultWasSpecified Whether the caller specified the default value or not. + * @return $this + * @see run() + * @see prepare() + */ + private function init( + callable $callable, + ?callable $finally, + bool $instantiatedUsingRun, + mixed $default, + bool $defaultWasSpecified, + ): self { + + $this->callable =& $callable; + $this->finally =& $finally; + + $this->instantiatedUsingRun = $instantiatedUsingRun; + + $this->hasCatchTypesInit($default, $defaultWasSpecified); + + return $this; + } + + + + /** + * Run a callable straight away, catch & report any exceptions (depending on the configuration). + * + * @param callable $callable The callable to run. + * @param mixed $default The default value to return if an exception occurs. + * @param callable|null $finally The callable to run after the main callable (whether an exception occurred or not) + * . + * @return mixed + * @throws Throwable Exceptions that weren't supposed to be caught. + */ + public static function run(callable $callable, mixed $default = null, ?callable $finally = null): mixed + { + $defaultWasSpecified = func_num_args() >= 2; + + return (new self())->init($callable, $finally, true, $default, $defaultWasSpecified)->execute(); + } + + /** + * Create a new Control instance, and prime it with the callback ready to run when execute() is called. + * + * @param callable $callable The callable to run. + * @param mixed $default The default value to return if an exception occurs. + * @return self + * @throws Throwable Exceptions that weren't supposed to be caught. + */ + public static function prepare(callable $callable, mixed $default = null, ?callable $finally = null): mixed + { + $defaultWasSpecified = func_num_args() >= 2; + + return (new self())->init($callable, $finally, false, $default, $defaultWasSpecified); + } + + + + /** + * Specify a callable to run after the main callable (whether an exception occurred or not). + * + * @param callable|null $finally The callable to run. + * + * @return $this + */ + public function finally(?callable $finally): static + { + $this->finally =& $finally; + + return $this; + } + + /** + * Let the caller capture the exception in a variable that's passed by reference. + * + * @param mixed $exception Pass-by-reference parameter that is updated with the exception (when relevant). + * + * @return $this + */ + public function getException(mixed &$exception): static + { + $this->exceptionHolder =& $exception; + + return $this; + } + + + + /** + * Execute the callable, and catch & report any exceptions (depending on the set-up and configuration). + * + * @return mixed + * @throws Throwable Exceptions that weren't supposed to be caught. + */ + public function execute(): mixed + { + $stepsBack = $this->instantiatedUsingRun + ? 2 + : 1; + + $metaData = [ + 'known' => [], + ]; + + MetaCallStackAPI::pushMetaData( + InternalSettings::META_DATA_TYPE__CONTROL_CALL, + spl_object_id($this), + $metaData, + $stepsBack + ); + + $this->exceptionHolder = null; + $finally = $this->finally; + try { + + return Framework::depInj()->call($this->callable); + + } catch (Throwable $e) { + + // let the caller access the exception via the variable they passed by reference + $this->exceptionHolder = $e; + + $inspector = $this->pickMatchingCatchType($e); + + // use the "finally" callable from the catch-type if it was set + $finally = $inspector?->getFinally() ?? $finally; + + return $this->processException($e, $inspector); + + } finally { + + if ($finally) { + Framework::depInj()->call($finally); + } + } + } + + /** + * Process the exception. + * + * @param Throwable $e The exception that occurred. + * @param Inspector|null $inspector The catch-type that was matched. + * @return mixed + * @throws Throwable Exceptions that weren't supposed to be caught. + */ + private function processException(Throwable $e, ?Inspector $inspector): mixed + { + // re-throw the exception if it wasn't supposed to be caught + if (is_null($inspector)) { + throw $e; + } + + $metaData = [ + 'known' => $inspector->resolveKnown(), + ]; + + // update with the "known" details now that they've been resolved + MetaCallStackAPI::replaceMetaData( + InternalSettings::META_DATA_TYPE__CONTROL_CALL, + spl_object_id($this), + $metaData + ); + + $context = $this->runCallbacksReportRethrow($e, $inspector); + + return $this->resolveDefaultValue($context, $inspector); + } + + + + /** + * Gather the callbacks to run. + * + * @param Inspector $inspector The catch-type that was matched. + * @return callable[] + */ + private function gatherCallbacks(Inspector $inspector): array + { + return array_merge($this->getGlobalCallbacks(), $inspector->resolveCallbacks()); + } + + + + /** + * Run the callbacks, reporting, and then rethrow the exception if necessary. + * + * @param Throwable $e The exception that occurred. + * @param Inspector $inspector The catch-type that was matched. + * @return Context|null + * @throws Throwable When the exception should be rethrown. + * @throws ClarityControlRuntimeException When a rethrow callback returns an invalid value. + */ + private function runCallbacksReportRethrow(Throwable $e, Inspector $inspector): ?Context + { + $shouldReport = $inspector->shouldReport(); + + $rethrowException = self::resolveExceptionToRethrow($inspector->pickRethrow(), $e); + + // make sure *something* should happen + // when these are off, the callbacks aren't run + if ((!$shouldReport) && (!$rethrowException)) { + return null; + } + + + + $callbacks = $this->gatherCallbacks($inspector); + $context = null; + if ((count($callbacks) || $shouldReport)) { // the only circumstances where a Context is needed + + $context = ContextAPI::buildContextFromException($e, $inspector->hasKnown(), spl_object_id($this)) + ->setReport($shouldReport) + ->setRethrow($rethrowException ?? false) + ->setDefault($inspector->resolveDefault()); + + $channels = $inspector->resolveChannels(); + if ($channels) { + $context->setChannels($channels); + } + + $level = $inspector->resolveLevel(); + if (!is_null($level)) { + $context->setLevel($level); + } + } + + // don't bother building + storing a context object + // when the only thing left to do is rethrow + if (!$context) { +// $this->runReporting($inspector->shouldReport(), $e); +// $this->runRethrow($inspector->shouldRethrow(), $e); +// return; + throw $e; + } + + + + try { + + $this->runCallbacks($context, $e, $callbacks); + // the $context may have been updated by the callbacks, so use its values instead of $inspector's + $this->runReporting($context->getReport(), $e); + $this->runRethrow($context->getRethrow(), $e); + + } finally { + ContextAPI::forgetExceptionContext($e); + } + + return $context; + } + + + + /** + * Run the callbacks. + * + * @param Context $context The Context instance to make available to the callbacks. + * @param Throwable $e The exception to report. + * @param callable[] $callbacks The callbacks to run. + * @return void + */ + private function runCallbacks(Context $context, Throwable $e, array $callbacks): void + { + foreach ($callbacks as $callback) { + + // check if the callbacks should continue to be called + if ((!$context->getReport()) && (!$context->getRethrow())) { + return; // don't continue + } + + $this->runCallback($e, $callback); + } + } + + + + /** + * Run a callback. + * + * @param Throwable $e The exception to report. + * @param callable $callback The callback to run. + * @return void + */ + private function runCallback(Throwable $e, callable $callback): void + { + Framework::depInj()->call($callback, ['exception' => $e, 'e' => $e]); + } + + /** + * Report the exception, if needed. + * + * @param boolean $report Whether the exception should be reported or not. + * @param Throwable $e The exception that occurred. + * @return void + */ + private function runReporting(bool $report, Throwable $e): void + { + if (!$report) { + return; + } + + report($e); + } + + /** + * Rethrow the exception, if needed. + * + * @param boolean|callable|Throwable $rethrow Whether the exception should be rethrown or not. + * @param Throwable $e The exception that occurred. + * @return void + * @throws ClarityControlRuntimeException When a rethrow callback returns an invalid value. + * @throws Throwable When the exception should be rethrown. + */ + private function runRethrow(bool|callable|Throwable $rethrow, Throwable $e): void + { + $rethrowException = self::resolveExceptionToRethrow($rethrow, $e); + + if ($rethrowException) { + throw $rethrowException; + } + } + + + + /** + * Determine the default value to return (if it was set). + * + * @param Context|null $context The context that was built. + * @param Inspector $inspector The catch-type that was matched. + * @return mixed + */ + private function resolveDefaultValue(?Context $context, Inspector $inspector): mixed + { + $default = $context?->getDefault() + ?? $inspector->resolveDefault(); + + return is_callable($default) + ? Framework::depInj()->call($default) + : $default; + } + + /** + * Resolve which Exception (Throwable) to rethrow. + * + * @param boolean|callable|Throwable|null $rethrow Whether to rethrow the exception or not, a callable to make the + * decision, or the exception to rethrow itself. + * @param Throwable $e The exception that occurred. + * @return Throwable|null + * @throws ClarityControlRuntimeException When a rethrow callback returns an invalid value. + */ + public static function resolveExceptionToRethrow(bool|callable|Throwable|null $rethrow, Throwable $e): ?Throwable + { + if (is_callable($rethrow)) { + $rethrow = Framework::depInj()->call($rethrow, ['exception' => $e, 'e' => $e]); + } + + if (is_null($rethrow)) { + return null; + } + + if ($rethrow === false) { + return null; + } + + if ($rethrow === true) { + return $e; + } + + if ($rethrow instanceof Throwable) { + return $rethrow; + } + + throw ClarityControlRuntimeException::invalidRethrowValue(); + } +} diff --git a/src/Exceptions/ClarityControlException.php b/src/Exceptions/ClarityControlException.php new file mode 100644 index 0000000..8e4f786 --- /dev/null +++ b/src/Exceptions/ClarityControlException.php @@ -0,0 +1,12 @@ +initialiseConfig(); + } + + /** + * Service-provider boot method. + * + * @return void + */ + public function boot(): void + { + $this->publishConfig(); + } + + + + /** + * Initialise the config settings file. + * + * @return void + */ + private function initialiseConfig(): void + { + $this->mergeConfigFrom( + __DIR__ . '/..' . InternalSettings::LARAVEL_CONTROL__CONFIG_PATH, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME + ); + } + + /** + * Allow the default config to be published. + * + * @return void + */ + private function publishConfig(): void + { + $src = __DIR__ . '/..' . InternalSettings::LARAVEL_CONTROL__CONFIG_PATH; + $dest = config_path(InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.php'); + + $this->publishes([$src => $dest], 'config'); + } +} diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..d784092 --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,31 @@ +fallbackCatchType = new CatchType(); + + if ($defaultWasSpecified) { + $this->fallbackCatchType->default($default); + } + + $this->hasBeenInitialised = true; + } + + /** + * Check to make sure this object has been initialised first. + * + * (essentially whether the caller called prepare() / run() or not). + * + * @param string $method The method that was called. + * @return void + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + private function checkForInitialisation(string $method): void + { + if ($this->hasBeenInitialised) { + return; + } + + throw ClarityControlInitialisationException::runPrepareFirst($method); + } + + + + /** + * Specify the types of exceptions to catch. + * + * @param string|CatchType|array $exceptionType The exception classes to catch. + * @param string|CatchType|array ...$exceptionType2 The exception classes to catch. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function catch(string|CatchType|array $exceptionType, string|CatchType|array ...$exceptionType2): static + { + $this->checkForInitialisation('catch'); + + /** @var array $exceptionTypes */ + $exceptionTypes = Support::normaliseArgs([], func_get_args()); + + foreach ($exceptionTypes as $tempExceptionType) { + if ($tempExceptionType instanceof CatchType) { + $this->catchTypes[] = $tempExceptionType; + } else { + $this->fallbackCatchType->catch($tempExceptionType); + } + } + + return $this; + } + + + + /** + * Specify string/s the exception message must match. + * + * @param string|string[] $matches The string/s the exception message needs to match. + * @param string|string[] ...$matches2 The string/s the exception message needs to match. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function match(string|array $matches, string|array ...$matches2): static + { + $this->checkForInitialisation('match'); + + call_user_func_array([$this->fallbackCatchType, 'match'], func_get_args()); + + return $this; + } + + + + /** + * Specify regex string/s the exception message must match. + * + * @param string|string[] $matches The regex string/s the exception message needs to match. + * @param string|string[] ...$matches2 The regex string/s the exception message needs to match. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function matchRegex(string|array $matches, string|array ...$matches2): static + { + $this->checkForInitialisation('matchRegex'); + + call_user_func_array([$this->fallbackCatchType, 'matchRegex'], func_get_args()); + + return $this; + } + + + + /** + * Specify a callback to run when an exception occurs. + * + * @param callable $callback The callback to run. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function callback(callable $callback): static + { + $this->checkForInitialisation('callback'); + + $this->fallbackCatchType->callback($callback); + + return $this; + } + + /** + * Specify callbacks to run when an exception occurs. + * + * @param callable|callable[] $callbacks The callback/s to run. + * @param callable|callable[] ...$callbacks2 The callback/s to run. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function callbacks(callable|array $callbacks, callable|array ...$callbacks2): static + { + $this->checkForInitialisation('callbacks'); + + call_user_func_array([$this->fallbackCatchType, 'callbacks'], func_get_args()); + + return $this; + } + + + + /** + * Specify issue/s that the exception is known to belong to. + * + * @param string|string[] $known The issue/s this exception is known to belong to. + * @param string|string[] ...$known2 The issue/s this exception is known to belong to. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function known(string|array $known, string|array ...$known2): static + { + $this->checkForInitialisation('known'); + + call_user_func_array([$this->fallbackCatchType, 'known'], func_get_args()); + + return $this; + } + + + + /** + * Specify a channel to log to. + * + * @param string $channel The channel to log to. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function channel(string $channel): static + { + $this->checkForInitialisation('channel'); + + $this->fallbackCatchType->channel($channel); + + return $this; + } + + /** + * Specify channels to log to. + * + * @param string|string[] $channel The channel/s to log to. + * @param string|string[] ...$channel2 The channel/s to log to. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function channels(string|array $channel, string|array ...$channel2): static + { + $this->checkForInitialisation('channels'); + + call_user_func_array([$this->fallbackCatchType, 'channels'], func_get_args()); + + return $this; + } + + + + /** + * Specify the log reporting level. + * + * @param string $level The log-level to use. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function level(string $level): static + { + $this->checkForInitialisation('level'); + + $this->fallbackCatchType->level($level); + + return $this; + } + + /** + * Set the log reporting level to "debug". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function debug(): static + { + $this->checkForInitialisation('debug'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_DEBUG); + + return $this; + } + + /** + * Set the log reporting level to "info". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function info(): static + { + $this->checkForInitialisation('info'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_INFO); + + return $this; + } + + /** + * Set the log reporting level to "notice". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function notice(): static + { + $this->checkForInitialisation('notice'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_NOTICE); + + return $this; + } + + /** + * Set the log reporting level to "warning". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function warning(): static + { + $this->checkForInitialisation('warning'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_WARNING); + + return $this; + } + + /** + * Set the log reporting level to "error". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function error(): static + { + $this->checkForInitialisation('error'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_ERROR); + + return $this; + } + + /** + * Set the log reporting level to "critical". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function critical(): static + { + $this->checkForInitialisation('critical'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_CRITICAL); + + return $this; + } + + /** + * Set the log reporting level to "alert". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function alert(): static + { + $this->checkForInitialisation('alert'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_ALERT); + + return $this; + } + + /** + * Set the log reporting level to "emergency". + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function emergency(): static + { + $this->checkForInitialisation('emergency'); + + $this->fallbackCatchType->level(Settings::REPORTING_LEVEL_EMERGENCY); + + return $this; + } + + + + /** + * Specify that exceptions should be reported (using the framework's reporting mechanism). + * + * @param boolean $report Whether to report exceptions or not. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function report(bool $report = true): static + { + $this->checkForInitialisation('report'); + + $this->fallbackCatchType->report($report); + + return $this; + } + + /** + * Specify that exceptions should not be reported (using the framework's reporting mechanism). + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function dontReport(): static + { + $this->checkForInitialisation('dontReport'); + + $this->fallbackCatchType->dontReport(); + + return $this; + } + + + + /** + * Specify whether caught exceptions should be re-thrown, or a callable to make the decision. + * + * @param boolean|callable $rethrow Whether to rethrow exceptions or not, or a callable to make the decision. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function rethrow(bool|callable $rethrow = true): static + { + $this->checkForInitialisation('rethrow'); + + $this->fallbackCatchType->rethrow($rethrow); + + return $this; + } + + /** + * Specify that caught exceptions should not be re-thrown. + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function dontRethrow(): static + { + $this->checkForInitialisation('dontRethrow'); + + $this->fallbackCatchType->dontRethrow(); + + return $this; + } + + + + /** + * Suppress the exception - don't report and don't rethrow it. + * + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function suppress(): static + { + $this->checkForInitialisation('suppress'); + + $this->fallbackCatchType->suppress(); + + return $this; + } + + + + /** + * Specify the default value that should be returned when an exception occurs. + * + * @param mixed $default The default value to use. + * @return $this + * @throws ClarityControlInitialisationException When the caller didn't call prepare() or run() first. + */ + public function default(mixed $default): static + { + $this->checkForInitialisation('default'); + + $this->fallbackCatchType->default($default); + + return $this; + } + + + + /** + * Find the catch-type that matches. + * + * @param Throwable $e The exception that occurred. + * @return Inspector|null + */ + private function pickMatchingCatchType(Throwable $e): ?Inspector + { + foreach ($this->resolveCatchTypesToCheck() as $catchType) { + $inspector = new Inspector($catchType, $this->fallbackCatchType); + if ($inspector->checkForMatch($e)) { + return $inspector; + } + } + + return null; + } + + /** + * Determine which CatchTypes to loop through and check. + * + * @return CatchType[] + */ + private function resolveCatchTypesToCheck(): array + { + $inspector = new Inspector($this->fallbackCatchType); + + // skip the fallbackCatchType if there are CatchTypes, and it doesn't specify exception types itself + if ((count($this->catchTypes)) && (!count($inspector->getExceptionClasses()))) { + return $this->catchTypes; + } + + return array_merge($this->catchTypes, [$this->fallbackCatchType]); + } +} diff --git a/src/Support/ControlTraits/HasGlobalCallbacks.php b/src/Support/ControlTraits/HasGlobalCallbacks.php new file mode 100644 index 0000000..4fb4dfc --- /dev/null +++ b/src/Support/ControlTraits/HasGlobalCallbacks.php @@ -0,0 +1,64 @@ +get(InternalSettings::CONTAINER_KEY__GLOBAL_CALLBACKS, []); + return $return; + } + + /** + * Set the "global" callbacks in global storage. + * + * @param callable[] $callbacks The global callbacks to store. + * @return void + */ + private static function setGlobalCallbacks(array $callbacks): void + { + Framework::depInj()->set(InternalSettings::CONTAINER_KEY__GLOBAL_CALLBACKS, $callbacks); + } +} diff --git a/src/Support/Inspector.php b/src/Support/Inspector.php new file mode 100644 index 0000000..aae2dd8 --- /dev/null +++ b/src/Support/Inspector.php @@ -0,0 +1,360 @@ +catchType = $catchType; + $this->fallbackCatchType = $fallbackCatchType + ?? $catchType; + } + + + + /** + * Check if an exception matches this catch-type. + * + * @param Throwable $e The exception that occurred. + * @return boolean + */ + public function checkForMatch(Throwable $e): bool + { + if (!$this->exceptionTypeMatches($e)) { + return false; + } + + $a = $this->exceptionMessageMatches($e); + $b = $this->exceptionMessageMatchesRegex($e); + if (($a === false || $b === false) && $a !== true && $b !== true) { + return false; + } + return true; + } + + /** + * Check if an exception's type (class) should be picked up by this catch-type. + * + * @param Throwable $e The exception that occurred. + * @return boolean + */ + private function exceptionTypeMatches(Throwable $e): bool + { + $classes = $this->catchType->exceptionClasses; + + if (!count($classes)) { + return true; // it doesn't need to match any + } + + foreach ($classes as $class) { + if ($e instanceof $class) { + return true; + } + } + return false; + } + + /** + * Check if an exception message should be picked up by this catch-type based on the allowed strings. + * + * @param Throwable $e The exception that occurred. + * @return boolean|null + */ + private function exceptionMessageMatches(Throwable $e): ?bool + { + $matchStrings = $this->catchType->matchStrings + ?: $this->fallbackCatchType->matchStrings; + + if (!count($matchStrings)) { + return null; // it doesn't need to match any + } + + return in_array($e->getMessage(), $matchStrings, true); + } + + /** + * Check if an exception message should be picked up by this catch-type based on the allowed regexes. + * + * @param Throwable $e The exception that occurred. + * @return boolean|null + */ + private function exceptionMessageMatchesRegex(Throwable $e): ?bool + { + $regexes = $this->catchType->matchRegexes + ?: $this->fallbackCatchType->matchRegexes; + + if (!count($regexes)) { + return null; // it doesn't need to match any + } + + foreach ($regexes as $regex) { + if (preg_match($regex, $e->getMessage())) { + return true; + } + } + return false; + } + + + + /** + * Retrieve the exception classes to catch. + * + * @return string[] + */ + public function getExceptionClasses(): array + { + return $this->catchType->exceptionClasses; + } + + /** + * Get the callbacks that have been set. + * + * @return callable[] + */ + public function resolveCallbacks(): array + { + return $this->catchType->callbacks + ?: $this->fallbackCatchType->callbacks; + } + + /** + * Get the known issues that have been set. + * + * @return string[] + */ + public function resolveKnown(): array + { + return $this->catchType->known + ?: $this->fallbackCatchType->known; + } + + /** + * Find out if there are any known issues. + * + * @return boolean + */ + public function hasKnown(): bool + { + return count($this->resolveKnown()) > 0; + } + + /** + * Get the channels that have been set. + * + * @return string[] + */ + public function resolveChannels(): array + { + return $this->catchType->channels + ?: $this->fallbackCatchType->channels + ?: Framework::config()->pickBestChannels($this->hasKnown()); + } + + /** + * Get the log reporting level that has been set. + * + * @return string|null + */ + public function resolveLevel(): ?string + { + return $this->catchType->level + ?? $this->fallbackCatchType->level + ?? Framework::config()->pickBestLevel($this->hasKnown()) + ?? Settings::REPORTING_LEVEL_ERROR; + } + + /** + * Check whether this catch-type intends the exception to be reported or not. + * + * @return boolean + */ + public function shouldReport(): bool + { + return $this->catchType->report + ?? $this->fallbackCatchType->report + ?? Framework::config()->getReport() + ?? true; // default true + } + + /** + * Check whether this catch-type intends the exception to be re-thrown or not. + * + * @return boolean|callable|null + */ + public function pickRethrow(): bool|callable|null + { + return $this->catchType->rethrow + ?? $this->fallbackCatchType->rethrow + ?? false; // default false + } + + /** + * Get the default value (used when an exception has occurred). + * + * @return mixed + */ + public function resolveDefault(): mixed + { + return $this->catchType->defaultIsSet + ? $this->catchType->default + : $this->fallbackCatchType->default; + } + + /** + * Retrieve the finally callable. + * + * @return callable|null + */ + public function getFinally(): ?callable + { + return $this->catchType->finally; + } + + + + + + /** + * Retrieve the exception classes to match (used while running tests). + * + * @internal + * + * @return string[] + */ + public function getRawExceptionClasses(): array + { + return $this->catchType->exceptionClasses; + } + + /** + * Retrieve the exception message strings to match (used while running tests). + * + * @internal + * + * @return string[] + */ + public function getRawMatchStrings(): array + { + return $this->catchType->matchStrings; + } + + /** + * Retrieve the exception message strings to match using regexes (used while running tests). + * + * @internal + * + * @return string[] + */ + public function getRawMatchRegexes(): array + { + return $this->catchType->matchRegexes; + } + + /** + * Retrieve the exception callbacks (used while running tests). + * + * @internal + * + * @return callable[] + */ + public function getRawCallbacks(): array + { + return $this->catchType->callbacks; + } + + /** + * Retrieve the known issues (used while running tests). + * + * @internal + * + * @return string[] + */ + public function getRawKnown(): array + { + return $this->catchType->known; + } + + /** + * Retrieve the channels (used while running tests). + * + * @internal + * + * @return string[] + */ + public function getRawChannels(): array + { + return $this->catchType->channels; + } + + /** + * Retrieve the report level (used while running tests). + * + * @internal + * + * @return string|null + */ + public function getRawLevel(): ?string + { + return $this->catchType->level; + } + + /** + * Retrieve whether to report exceptions or not (used while running tests). + * + * @internal + * + * @return boolean|null + */ + public function getRawReport(): ?bool + { + return $this->catchType->report; + } + + /** + * Retrieve whether to rethrow exceptions or not, or the callable that decides (used while running tests). + * + * @internal + * + * @return boolean|callable|null + */ + public function getRawRethrow(): bool|callable|null + { + return $this->catchType->rethrow; + } + + /** + * Retrieve the default value (used while running tests). + * + * @internal + * + * @return mixed + */ + public function getRawDefault(): mixed + { + return $this->catchType->default; + } +} diff --git a/src/Support/InternalSettings.php b/src/Support/InternalSettings.php new file mode 100644 index 0000000..142a910 --- /dev/null +++ b/src/Support/InternalSettings.php @@ -0,0 +1,24 @@ + $expected The expected meta-data. + * @return void + */ + public static function test_meta_purging( + bool $oneATrigger, + bool $oneARethrow, + bool $oneBTrigger, + bool $oneBRethrow, + bool $twoATrigger, + bool $twoARethrow, + bool $twoBTrigger, + bool $twoBRethrow, + array $expected + ): void { + + $allCondensedMetaData = []; + $callback = function (Context $context) use (&$allCondensedMetaData) { + + $condensedMetaData = []; + foreach ($context->getCallStack()->getMeta() as $meta) { + + if ($meta instanceof LastApplicationFrameMeta) { + $condensedMetaData[] = 'last-application-frame'; + } elseif ($meta instanceof ExceptionThrownMeta) { + $condensedMetaData[] = 'exception-thrown'; + } elseif ($meta instanceof ExceptionCaughtMeta) { + $condensedMetaData[] = 'exception-caught'; + } elseif ($meta instanceof ContextMeta) { + $condensedMetaData[] = $meta->getContext(); + } elseif ($meta instanceof CallMeta) { + $condensedMetaData[] = [ + 'known' => $meta->getKnown()[0] ?? null, + 'caughtHere' => $meta->wasCaughtHere(), + ]; + } else { + throw new Exception('Unexpected Meta class: ' . get_class($meta)); + } + } + + $allCondensedMetaData[] = $condensedMetaData; + }; + + + + $oneB = function () use ($oneBTrigger, $oneBRethrow, $callback) { + + Clarity::context('context one-b'); + + Control::prepare(self::maybeThrow($oneBTrigger)) + ->known('known one-b') + ->callback($callback) + ->rethrow($oneBRethrow) + ->execute(); + }; + + $oneA = function () use ($oneATrigger, $oneARethrow, $oneB, $callback) { + + Clarity::context('context one-a'); + + Control::prepare(self::maybeThrow($oneATrigger, $oneB)) + ->known('known one-a') + ->callback($callback) + ->rethrow($oneARethrow) + ->execute(); + }; + + + + $twoB = function () use ($twoBTrigger, $twoBRethrow, $callback) { + + Clarity::context('context two-b'); + + Control::prepare(self::maybeThrow($twoBTrigger)) + ->known('known two-b') + ->callback($callback) + ->rethrow($twoBRethrow) + ->execute(); + }; + + $twoA = function () use ($twoATrigger, $twoARethrow, $twoB, $callback) { + + Clarity::context('context two-a'); + + Control::prepare(self::maybeThrow($twoATrigger, $twoB)) + ->known('known two-a') + ->callback($callback) + ->rethrow($twoARethrow) + ->execute(); + }; + + + + // start making the calls + Clarity::context('context one'); + + Control::prepare($oneA) + ->known('known one') + ->callback($callback) + ->execute(); + + Clarity::context('context two'); + + Control::prepare($twoA) + ->known('known two') + ->callback($callback) + ->execute(); + + + + self::assertSame($expected, $allCondensedMetaData); + } + + /** + * DataProvider for test_meta_purging(). + * + * @return array + */ + public static function metaPurgingDataProvider() + { + $return = []; + + foreach ([false, true] as $oneATrigger) { + foreach ([false, true] as $oneARethrow) { + foreach ([false, true] as $oneBTrigger) { + foreach ([false, true] as $oneBRethrow) { + foreach ([false, true] as $twoATrigger) { + foreach ([false, true] as $twoARethrow) { + foreach ([false, true] as $twoBTrigger) { + foreach ([false, true] as $twoBRethrow) { + + $expected = self::resolveExpected( + $oneATrigger, + $oneARethrow, + $oneBTrigger, + $oneBRethrow, + $twoATrigger, + $twoARethrow, + $twoBTrigger, + $twoBRethrow, + ); + + if (!is_null($expected)) { + $return[] = [ + 'oneATrigger' => $oneATrigger, + 'oneARethrow' => $oneARethrow, + 'oneBTrigger' => $oneBTrigger, + 'oneBRethrow' => $oneBRethrow, + 'twoATrigger' => $twoATrigger, + 'twoARethrow' => $twoARethrow, + 'twoBTrigger' => $twoBTrigger, + 'twoBRethrow' => $twoBRethrow, + 'expected' => $expected, + ]; + } + } + } + } + } + } + } + } + } + + return $return; + } + + /** + * Determine what the outcome should be for the given trigger and rethrow settings. + * + * @param boolean $oneATrigger Should closure "one-a" trigger an exception?. + * @param boolean $oneARethrow Should closure "one-a" re-throw exceptions?. + * @param boolean $oneBTrigger Should closure "one-b" trigger an exception?. + * @param boolean $oneBRethrow Should closure "one-b" re-throw exceptions?. + * @param boolean $twoATrigger Should closure "two-a" trigger an exception?. + * @param boolean $twoARethrow Should closure "two-a" re-throw exceptions?. + * @param boolean $twoBTrigger Should closure "two-b" trigger an exception?. + * @param boolean $twoBRethrow Should closure "two-b" re-throw exceptions?. + * @return array|null + */ + private static function resolveExpected( + bool $oneATrigger, + bool $oneARethrow, + bool $oneBTrigger, + bool $oneBRethrow, + bool $twoATrigger, + bool $twoARethrow, + bool $twoBTrigger, + bool $twoBRethrow, + ): ?array { + + // "one-b" can't trigger, as "one-a" will trigger first + if (($oneBTrigger) && ($oneATrigger)) { + return null; + } + // "one-b" can't rethrow, as no exception will have been triggered + if (($oneBRethrow) && (!$oneBTrigger)) { + return null; + } + // "one-a" can't rethrow, as no exception will have been triggered + if ($oneARethrow) { + if ((!$oneATrigger) && (!$oneBTrigger || !$oneBRethrow)) { + return null; + } + } + + // path "two" shouldn't be used, because an exception was already triggered via path "one" + if (($twoATrigger || $twoBTrigger) && ($oneATrigger || $oneBTrigger)) { + return null; + } + + // "two-b" can't trigger, as "two-a" will trigger first + if (($twoBTrigger) && ($twoATrigger)) { + return null; + } + // "two-b" can't rethrow, as no exception will have been triggered + if (($twoBRethrow) && (!$twoBTrigger)) { + return null; + } + // "two-a" can't rethrow, as no exception will have been triggered + if ($twoARethrow) { + if ((!$twoATrigger) && (!$twoBTrigger || !$twoBRethrow)) { + return null; + } + } + + + + // go down path "one" + $snapshot = self::makeSnapshot( + 'one', + $oneATrigger, + $oneARethrow, + $oneBTrigger, + $oneBRethrow + ); + + // go down path "two" + if (count($snapshot) <= 1) { + $frameToAdd = self::makeFrame('one', false, false, false); + $newSnapshot = self::makeSnapshot( + 'two', + $twoATrigger, + $twoARethrow, + $twoBTrigger, + $twoBRethrow, + $frameToAdd + ); + $snapshot = array_merge($snapshot, $newSnapshot); + } + + return self::formatSnapshots( + self::runSnapshotThroughSteps($snapshot) + ); + } + + /** + * Build one snapshot worth of frames. + * + * @param string $name The name that representing the call-level. + * @param boolean $aTrigger Should closure "a" trigger an exception?. + * @param boolean $aRethrow Should closure "a" re-throw exceptions?. + * @param boolean $bTrigger Should closure "b" trigger an exception?. + * @param boolean $bRethrow Should closure "b" re-throw exceptions?. + * @param mixed[]|null $frameToAdd A frame to arbitrarily add to the beginning, provided some other frame was used. + * @return array> + */ + private static function makeSnapshot( + string $name, + bool $aTrigger, + bool $aRethrow, + bool $bTrigger, + bool $bRethrow, + ?array $frameToAdd = null, + ): array { + + $frameShouldBeIncluded = false; + $exceptionIsActive = true; + $snapshot = []; + + if ($bTrigger) { + + $snapshot[] = self::makeFrame( + "$name-b", + $exceptionIsActive, + true, + $bTrigger, + ); + $frameShouldBeIncluded = true; + + if (!$bRethrow) { + $exceptionIsActive = false; + } + } + + if ($aTrigger || $frameShouldBeIncluded) { + + $snapshot[] = self::makeFrame( + "$name-a", + $exceptionIsActive, + true, + $aTrigger, + ); + $frameShouldBeIncluded = true; + + if (!$aRethrow) { + $exceptionIsActive = false; + } + } + + if ($frameShouldBeIncluded) { + $snapshot[] = self::makeFrame( + $name, + $exceptionIsActive, + true, + false, + ); + } + + if (count($snapshot) && $frameToAdd) { + $snapshot[] = $frameToAdd; + } + + return $snapshot; + } + + /** + * Make one frame's worth of meta-object details. + * + * @param string $name The name that representing the call-level. + * @param boolean $canCatch Whether this frame will catch the exception at some point. + * @param boolean $includeExecution Whether to include the execution meta-data or not. + * @param boolean $willTrigger Whether the exception will be triggered by this closure or not. + * @return array + */ + private static function makeFrame( + string $name, + bool $canCatch, + bool $includeExecution, + bool $willTrigger + ): array { + + $return = []; + + $return[] = "context $name"; + + if ($includeExecution) { + $return[] = [ + "known" => "known $name", + "caughtHere" => null, + "canCatch" => $canCatch, // will be removed later + ]; + } + + if ($willTrigger) { + $return[] = 'last-application-frame'; + $return[] = 'exception-thrown'; + } + + return $return; + } + + /** + * Run a callstack snapshot through the steps, where each "frame" catches the exception. + * + * @param array> $snapshot The snapshot to loop through. + * @return array>> + */ + private static function runSnapshotThroughSteps(array $snapshot): array + { + $snapshots = []; + + // make this many snapshots + for ($count = 0; $count < count($snapshot); $count++) { + + // loop through each frame + $newSnapshot = $snapshot; + $foundFrameThatCaught = false; + for ($index = 0; $index < count($newSnapshot); $index++) { + + // look for "call" entry + if (!array_key_exists(1, $newSnapshot[$index])) { + continue; + } + + if (!is_array($newSnapshot[$index][1])) { + continue; + } + + /** @var array $callMeta */ + $callMeta = $newSnapshot[$index][1]; + + // remove the "known" setting if deeper frame has already caught it + if ($foundFrameThatCaught) { + $callMeta['known'] = null; + } + + // update the "caughtHere" setting for this frame + $caughtHere = ($index == $count) && $callMeta['canCatch']; + + $foundFrameThatCaught = $foundFrameThatCaught || $caughtHere; + + $callMeta['caughtHere'] = $caughtHere; + unset($callMeta['canCatch']); + + $newSnapshot[$index][1] = $callMeta; + + // add in the "exception-caught" meta-data after the "call" one + if ($caughtHere) { + array_splice($newSnapshot[$index], 2, 0, ['exception-caught']); + } + } + + // keep the new snapshot if the exception was caught *somewhere* + if ($foundFrameThatCaught) { + $snapshots[] = $newSnapshot; + } + } + + return $snapshots; + } + + /** + * Format the snapshot to be in the format that's used when running the test. + * + * @param array>> $snapshots The snapshots to process. + * @return array> + */ + private static function formatSnapshots(array $snapshots): array + { + foreach ($snapshots as $index => $snapshot) { + + // put the frames in the order that they'll appear + $snapshot = array_reverse($snapshot); + + // flatten them, so the meta-details for each frame are in a single list + $flattenedSnapshot = []; + /** @var array $frame */ + foreach ($snapshot as $frame) { + $flattenedSnapshot = array_merge($flattenedSnapshot, $frame); + } + + $snapshots[$index] = $flattenedSnapshot; + } + + return $snapshots; + } + + + + /** + * Build a closure that throws a new exception. + * + * @param boolean $shouldThrow Whether an exception should be thrown or not. + * @param callable|null $alternative The callable to use when not throwing an exception. + * @return callable + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + private static function maybeThrow(bool $shouldThrow, ?callable $alternative = null): callable + { + $alternative ??= fn() => 'a'; + + return $shouldThrow + ? fn() => throw new Exception() + : $alternative; + } +} diff --git a/tests/Integration/MetaCallStackPruning2Test.php b/tests/Integration/MetaCallStackPruning2Test.php new file mode 100644 index 0000000..e52f6ba --- /dev/null +++ b/tests/Integration/MetaCallStackPruning2Test.php @@ -0,0 +1,185 @@ +getCallStack(); + + self::assertCount(3 + $expectedCallMetaCount + $expectedContextMetaCount, $callStack->getMeta()); + self::assertCount($expectedCallMetaCount, $callStack->getMeta(CallMeta::class)); + self::assertCount($expectedContextMetaCount, $callStack->getMeta(ContextMeta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionThrownMeta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionCaughtMeta::class)); + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + + if ($expectedContextMetaCount) { + /** @var ContextMeta[] $contextMeta */ + $contextMeta = $callStack->getMeta(ContextMeta::class); + self::assertSame('context3', $contextMeta[0]->getContext()); + } + }; + + $closure3 = function () use ($closure3AddContext, $closure3ClarityRunAndRethrow) { + if ($closure3AddContext) { + Clarity::context('context3'); + } + + $closure3ClarityRunAndRethrow + ? Control::prepare(fn() => throw new Exception())->rethrow()->execute() + : throw new Exception(); + }; + + $closure2 = function () use ($closure2AddContext, $closure2ClarityRunAndCatch) { + if ($closure2AddContext) { + Clarity::context('context2'); + } + + if ($closure2ClarityRunAndCatch) { + Control::run(fn() => throw new Exception()); + } + }; + + $closure1 = function () use ($closure2, $closure3) { + $closure2(); + $closure3(); + }; + + Control::prepare($closure1) + ->callback($callback) + ->execute(); + } + + /** + * DataProvider for test_purge_of_meta_data_after_fork_in_execution_tree(). + * + * @return array> + */ + public static function purgeMetaDataDataProvider(): array + { + $return = []; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => true, + 'closure3AddContext' => false, + 'closure3ClarityRunAndRethrow' => false, + 'expectedCallMetaCount' => 1, + 'expectedContextMetaCount' => 0, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => false, + 'closure3AddContext' => false, + 'closure3ClarityRunAndRethrow' => false, + 'expectedCallMetaCount' => 1, + 'expectedContextMetaCount' => 0, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => true, + 'closure3AddContext' => true, + 'closure3ClarityRunAndRethrow' => false, + 'expectedCallMetaCount' => 1, + 'expectedContextMetaCount' => 1, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => false, + 'closure3AddContext' => true, + 'closure3ClarityRunAndRethrow' => false, + 'expectedCallMetaCount' => 1, + 'expectedContextMetaCount' => 1, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => true, + 'closure3AddContext' => false, + 'closure3ClarityRunAndRethrow' => true, + 'expectedCallMetaCount' => 2, + 'expectedContextMetaCount' => 0, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => false, + 'closure3AddContext' => false, + 'closure3ClarityRunAndRethrow' => true, + 'expectedCallMetaCount' => 2, + 'expectedContextMetaCount' => 0, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => true, + 'closure3AddContext' => true, + 'closure3ClarityRunAndRethrow' => true, + 'expectedCallMetaCount' => 2, + 'expectedContextMetaCount' => 1, + ]; + + $return[] = [ + 'closure2AddContext' => true, + 'closure2ClarityRunAndCatch' => false, + 'closure3AddContext' => true, + 'closure3ClarityRunAndRethrow' => true, + 'expectedCallMetaCount' => 2, + 'expectedContextMetaCount' => 1, + ]; + + return $return; + } +} diff --git a/tests/LaravelTestCase.php b/tests/LaravelTestCase.php new file mode 100644 index 0000000..b1dd54f --- /dev/null +++ b/tests/LaravelTestCase.php @@ -0,0 +1,29 @@ + + */ + // phpcs:ignore + protected function getPackageProviders($app) + { + return [ + ContextServiceProvider::class, + ControlServiceProvider::class, + ]; + } +} diff --git a/tests/PHPUnitTestCase.php b/tests/PHPUnitTestCase.php new file mode 100644 index 0000000..27afd9a --- /dev/null +++ b/tests/PHPUnitTestCase.php @@ -0,0 +1,12 @@ +method = $method; + $this->args = $args; + } + + /** + * Retrieve the method. + * + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * Retrieve the arguments. + * + * @return mixed[] + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * Retrieve the arguments, with arrays flattened out. + * + * @param callable|null $filterCallback The callback to filter the arguments by. + * @return mixed[] + */ + public function getArgsFlat(callable $filterCallback = null): array + { + $flatArgs = []; + foreach ($this->args as $arg) { + $arg = is_array($arg) + ? $arg + : [$arg]; + $flatArgs = array_merge($flatArgs, $arg); + } + + return !is_null($filterCallback) + ? array_filter($flatArgs, $filterCallback) + : $flatArgs; + } +} diff --git a/tests/Support/MethodCalls.php b/tests/Support/MethodCalls.php new file mode 100644 index 0000000..67f9f50 --- /dev/null +++ b/tests/Support/MethodCalls.php @@ -0,0 +1,118 @@ +methodCalls[] = new MethodCall($method, $args); + } + return $this; + } + + /** + * Retrieve the method calls, with an optional method name filter. + * + * @param string|string[]|null $method The method type to filter by. + * @return MethodCall[] + */ + public function getCalls(string|array $method = null): array + { + if (is_null($method)) { + return $this->methodCalls; + } + + $methods = is_array($method) + ? $method + : [$method]; + + $methodCalls = []; + foreach ($this->methodCalls as $methodCall) { + if (in_array($methodCall->getMethod(), $methods)) { + $methodCalls[] = $methodCall; + } + } + return $methodCalls; + } + + /** + * Check if any methods have been specified. + * + * @return boolean + */ + public function hasCalls(): bool + { + return count($this->methodCalls) > 0; + } + + /** + * Check if a particular method call exists. + * + * @param string $method The method type to filter by. + * @return boolean + */ + public function hasCall(string $method): bool + { + foreach ($this->methodCalls as $methodCall) { + if ($methodCall->getMethod() == $method) { + return true; + } + } + return false; + } + + /** + * Pick all the parameters of a particular method call. + * + * @param string $method The method call type to pick arguments from. + * @param callable|null $filterCallback The callback to filter the arguments by. + * @return mixed[] + */ + public function getAllCallArgsFlat(string $method, callable $filterCallback = null): array + { + return collect($this->methodCalls) + ->filter(fn(MethodCall $m) => $m->getMethod() == $method) + ->map(fn(MethodCall $m) => $m->getArgsFlat($filterCallback)) + ->flatten(1) + ->toArray(); + } +} diff --git a/tests/Unit/CatchTypeUnitTest.php b/tests/Unit/CatchTypeUnitTest.php new file mode 100644 index 0000000..27a44d0 --- /dev/null +++ b/tests/Unit/CatchTypeUnitTest.php @@ -0,0 +1,505 @@ +getCalls() as $methodCall) { + + $method = $methodCall->getMethod(); + $args = $methodCall->getArgs(); + + // place the exception callback into the args for calls to callback() / callbacks() + foreach ($args as $index => $arg) { + if ((in_array($method, ['callback', 'callbacks'])) && ($arg ?? null)) { + $args[$index] = $exceptionCallbacks[] = fn() => 'Hello'; + } + } + + $toCall = [$catchTypeObject ?? CatchType::class, $method]; + if (is_callable($toCall)) { + /** @var CatchType $catchTypeObject */ + $catchTypeObject = call_user_func_array($toCall, $args); + } else { + throw new Exception("Can't call method $method on class CatchType"); + } + } + } catch (Throwable $e) { +// dump("Exception: \"{$e->getMessage()}\" in {$e->getFile()}:{$e->getLine()}"); + $exceptionWasThrownUponInit = true; + } + + self::assertSame($expectExceptionUponInit, $exceptionWasThrownUponInit); + if ($exceptionWasThrownUponInit) { + return; + } + + if (is_null($catchTypeObject)) { + return; + } + + $inspector = new Inspector($catchTypeObject); + self::assertSame($expectedExceptionClasses, $inspector->getRawExceptionClasses()); + self::assertSame($expectedMatchStrings, $inspector->getRawMatchStrings()); + self::assertSame($expectedMatchRegexes, $inspector->getRawMatchRegexes()); + + if (($initMethodCalls->hasCall('callback')) || ($initMethodCalls->hasCall('callbacks'))) { + foreach ($inspector->getRawCallbacks() as $callback) { + self::assertSame(array_shift($exceptionCallbacks), $callback); + } + } + + self::assertSame($expectedKnownStrings, $inspector->getRawKnown()); + self::assertSame($expectedChannels, $inspector->getRawChannels()); + self::assertSame($expectedLevel, $inspector->getRawLevel()); + self::assertSame($expectedReport, $inspector->getRawReport()); + self::assertSame($expectedRethrow, $inspector->getRawRethrow()); + self::assertSame($expectedDefault, $inspector->getRawDefault()); + self::assertSame($expectedFinally, $inspector->getFinally()); + } + + /** + * DataProvider for test_that_catchtype_operates_properly(). + * + * Provide the different combinations of how the CatchType object can be set up and called. + * + * @return array> + */ + public static function catchTypeDataProvider(): array + { + $catchCombinations = [ + null, // don't call + [Throwable::class], + [InvalidArgumentException::class], + [DivisionByZeroError::class], + ]; + + $matchCombinations = [ + null, // don't call + ['Something happened'], + ['(NO MATCH)'], + ]; + + $matchRegexCombinations = [ + null, // don't call + ['/Something/'], + ['(NO MATCH)'], + ]; + + $callbackCombinations = [ + null, // don't call + [true], // is replaced with the callback later, inside the test + ]; + + $knownCombinations = [ + null, // don't call + ['ABC-123'], +// [['ABC-123', 'DEF-456']], + ]; + + $channelCombinations = [ + null, // don't call + ['stack'], + ]; + + $levelCombinations = [ + null, // don't call + ['info'], +// ['BLAH'], // error + ]; + + $reportCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $rethrowCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $finallyCombinations = [ + null, // don't call + [], // called with no arguments + [fn() => true], + ]; + + + + $return = []; + + foreach ($catchCombinations as $catch) { + foreach ($matchCombinations as $match) { + foreach ($matchRegexCombinations as $matchRegex) { + foreach ($callbackCombinations as $callback) { + foreach ($knownCombinations as $known) { + foreach ($channelCombinations as $channel) { + foreach ($levelCombinations as $level) { + foreach ($reportCombinations as $report) { + foreach ($rethrowCombinations as $rethrow) { + foreach ($finallyCombinations as $finally) { + + $initMethodCalls = MethodCalls::new() + ->add('catch', $catch) + ->add('match', $match) + ->add('matchRegex', $matchRegex) + ->add('callback', $callback) + ->add('known', $known) + ->add('channel', $channel) + ->add('level', $level) + ->add('report', $report) +// ->add('dontReport', $dontReport) + ->add('rethrow', $rethrow) +// ->add('dontRethrow', $dontRethrow) + ->add('finally', $finally) +// ->add('execute', $execute) + ; + + $return[] = self::buildParams($initMethodCalls); + } + } + } + } + } + } + } + } + } + } + + + + $return[] = self::buildParams(MethodCalls::add('catch')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('catch', [Exception::class]) + ->add('catch', [Throwable::class, DivisionByZeroError::class]) + ->add('catch', [[Exception::class, DivisionByZeroError::class]])); + + + + $return[] = self::buildParams(MethodCalls::add('match')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('match', ['Blah1']) + ->add('match', ['Blah2', 'Blah3']) + ->add('match', [['Blah2', 'Blah4']])); + + $return[] = self::buildParams(MethodCalls::add('matchRegex')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('matchRegex', ['/^Blah1$/']) + ->add('matchRegex', ['/^Blah2$/', '/^Blah3$/']) + ->add('matchRegex', [['/^Blah2$/', '/^Blah4$/']])); + + + + $return[] = self::buildParams(MethodCalls::add('callback')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('callback', [true]) + ->add('callback', [true]) + ->add('callback', [true])); + $return[] = self::buildParams(MethodCalls::add('callbacks')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('callbacks', [true]) + ->add('callbacks', [true, true]) + ->add('callbacks', [[true, true]]) + ->add('callback', [[true]])); + + + + $return[] = self::buildParams(MethodCalls::add('known')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('known', ['ABC',]) + ->add('known', ['GHI', 'XYZ']) + ->add('known', [['DEF', 'XYZ']])); + + + + $return[] = self::buildParams(MethodCalls::add('channel')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('channel', ['a']) + ->add('channel', ['c']) + ->add('channel', ['b'])); + $return[] = self::buildParams(MethodCalls::add('channels')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('channels', ['a']) + ->add('channels', ['b', 'c']) + ->add('channels', [['b', 'd']]) + ->add('channel', ['z'])); + + + + $return[] = self::buildParams(MethodCalls::add('level')); // error - no params + $return[] = self::buildParams(MethodCalls::add('level', ['BLAH'])); // error - invalid level + $return[] = self::buildParams(MethodCalls + ::add('level', [Settings::REPORTING_LEVEL_INFO]) + ->add('level', [Settings::REPORTING_LEVEL_WARNING]) + ->add('level', [Settings::REPORTING_LEVEL_INFO])); + + foreach (Settings::LOG_LEVELS as $level) { + $return[] = self::buildParams(MethodCalls::add($level)); + } + + $return[] = self::buildParams( + MethodCalls + ::add(Settings::REPORTING_LEVEL_DEBUG) + ->add(Settings::REPORTING_LEVEL_EMERGENCY) + ); + + $return[] = self::buildParams( + MethodCalls + ::add(Settings::REPORTING_LEVEL_DEBUG) + ->add('level', [Settings::REPORTING_LEVEL_WARNING]) + ); + + $return[] = self::buildParams( + MethodCalls + ::add(Settings::REPORTING_LEVEL_DEBUG) + ->add('level', [Settings::REPORTING_LEVEL_WARNING]) + ->add(Settings::REPORTING_LEVEL_INFO) + ); + + + + $return[] = self::buildParams(MethodCalls::add('report')); + $return[] = self::buildParams(MethodCalls::add('report', [true])); + $return[] = self::buildParams(MethodCalls::add('report', [false])); + $return[] = self::buildParams(MethodCalls::add('dontReport')); + $return[] = self::buildParams(MethodCalls::add('report')->add('dontReport')); + $return[] = self::buildParams(MethodCalls::add('report')->add('dontReport')->add('report')); + + + + $return[] = self::buildParams(MethodCalls::add('rethrow')); + $return[] = self::buildParams(MethodCalls::add('rethrow', [true])); + $return[] = self::buildParams(MethodCalls::add('rethrow', [false])); + $return[] = self::buildParams(MethodCalls::add('rethrow', [fn(Throwable $exception) => true])); + $return[] = self::buildParams(MethodCalls::add('dontRethrow')); + $return[] = self::buildParams(MethodCalls::add('rethrow')->add('dontRethrow')); + $return[] = self::buildParams( + MethodCalls + ::add('rethrow', [fn(Throwable $exception) => true]) + ->add('dontRethrow') + ->add('rethrow') + ); + $return[] = self::buildParams( + MethodCalls + ::add('rethrow', [fn(Throwable $exception) => true]) + ->add('dontRethrow') + ->add('rethrow', [fn(Throwable $exception) => true]) + ); + + + + $return[] = self::buildParams( + MethodCalls + ::add('report') + ->add('rethrow') + ->add('suppress') + ); + + + + $return[] = self::buildParams(MethodCalls::add('default')); // error - no params + $return[] = self::buildParams(MethodCalls::add('default', ['something'])); + + return $return; + } + + + + /** + * Determine the parameters to pass to the test_that_catchtype_operates_properly test. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @return array + */ + private static function buildParams(MethodCalls $initMethodCalls): array + { + $expectExceptionUponInit = self::willExceptionBeThrownUponInit($initMethodCalls); + + $exceptionTypes = []; + $matchStrings = []; + $matchRegexs = []; + $knownStrings = []; + $channels = []; + $report = null; + $rethrow = null; + $default = null; + $finally = null; + + $avu = fn(array $array) => array_values(array_unique($array)); + + if (!$expectExceptionUponInit) { + + foreach ($initMethodCalls->getCalls() as $methodCall) { + match ($methodCall->getMethod()) { + 'catch' => $exceptionTypes = $avu([...$exceptionTypes, ...$methodCall->getArgsFlat()]), + 'match' => $matchStrings = $avu([...$matchStrings, ...$methodCall->getArgsFlat()]), + 'matchRegex' => $matchRegexs = $avu([...$matchRegexs, ...$methodCall->getArgsFlat()]), + 'known' => $knownStrings = $avu([...$knownStrings, ...$methodCall->getArgsFlat()]), + 'channel', + 'channels' => $channels = $avu([...$channels, ...$methodCall->getArgsFlat()]), + 'report' => $report = $methodCall->getArgs()[0] ?? true, + 'dontReport' => $report = false, + 'rethrow' => $rethrow = $methodCall->getArgs()[0] ?? true, + 'dontRethrow' => $rethrow = false, + 'suppress' => $report = $rethrow = false, + 'default' => $default = $methodCall->getArgs()[0] ?? null, + 'finally' => $finally = $methodCall->getArgs()[0] ?? null, + default => null, + }; + } + } + + return [ + 'initMethodCalls' => $initMethodCalls, + 'expectExceptionUponInit' => $expectExceptionUponInit, + 'expectedExceptionClasses' => $exceptionTypes, + 'expectedMatchStrings' => $matchStrings, + 'expectedMatchRegexes' => $matchRegexs, + 'expectedKnownStrings' => $knownStrings, + 'expectedChannels' => $channels, + 'expectedLevel' => self::pickLevel($initMethodCalls), + 'expectedReport' => $report, + 'expectedRethrow' => $rethrow, + 'expectedDefault' => $default, + 'expectedFinally' => $finally, + ]; + } + + + + /** + * Determine if an exception will be triggered when setting up the CatchType instance. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @return boolean + */ + private static function willExceptionBeThrownUponInit(MethodCalls $initMethodCalls): bool + { + $methodsAllowedToHaveNoParameters = [ + 'report', + 'dontReport', + 'rethrow', + 'dontRethrow', + 'suppress', + Settings::REPORTING_LEVEL_DEBUG, + Settings::REPORTING_LEVEL_INFO, + Settings::REPORTING_LEVEL_NOTICE, + Settings::REPORTING_LEVEL_WARNING, + Settings::REPORTING_LEVEL_ERROR, + Settings::REPORTING_LEVEL_CRITICAL, + Settings::REPORTING_LEVEL_ALERT, + Settings::REPORTING_LEVEL_EMERGENCY, + ]; + + // check the "level" arguments + $allLevels = collect($initMethodCalls->getCalls('level')) + ->map(fn(MethodCall $m) => $m->getArgsFlat()) + ->flatten(1) + ->toArray(); + + foreach ($allLevels as $arg) { + if (!in_array($arg, ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug'])) { + return true; // init error + } + } + + foreach ($initMethodCalls->getCalls() as $methodCall) { + // allowed to be called with no parameters + if (in_array($methodCall->getMethod(), $methodsAllowedToHaveNoParameters)) { + continue; + } + // NOT allowed to be called without parameters + if (!count($methodCall->getArgs())) { + return true; // init error + } + } + + return false; + } + + /** + * Determine what the log reporting level should be set to. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @return string|null + */ + private static function pickLevel(MethodCalls $initMethodCalls): ?string + { + $level = null; + foreach ($initMethodCalls->getCalls() as $methodCall) { + match ($methodCall->getMethod()) { + 'level' => $level = $methodCall->getArgs()[0] ?? null, + Settings::REPORTING_LEVEL_DEBUG => $level = Settings::REPORTING_LEVEL_DEBUG, + Settings::REPORTING_LEVEL_INFO => $level = Settings::REPORTING_LEVEL_INFO, + Settings::REPORTING_LEVEL_NOTICE => $level = Settings::REPORTING_LEVEL_NOTICE, + Settings::REPORTING_LEVEL_WARNING => $level = Settings::REPORTING_LEVEL_WARNING, + Settings::REPORTING_LEVEL_ERROR => $level = Settings::REPORTING_LEVEL_ERROR, + Settings::REPORTING_LEVEL_CRITICAL => $level = Settings::REPORTING_LEVEL_CRITICAL, + Settings::REPORTING_LEVEL_ALERT => $level = Settings::REPORTING_LEVEL_ALERT, + Settings::REPORTING_LEVEL_EMERGENCY => $level = Settings::REPORTING_LEVEL_EMERGENCY, + default => null + }; + } + + /** @var string|null $level */ + return $level; + } +} diff --git a/tests/Unit/ControlReportingLevelUnitTest.php b/tests/Unit/ControlReportingLevelUnitTest.php new file mode 100644 index 0000000..0e4e000 --- /dev/null +++ b/tests/Unit/ControlReportingLevelUnitTest.php @@ -0,0 +1,126 @@ + self::assertSame($expectedLevel, $context->getLevel()); + + // initialise the Control object + $control = Control::prepare(fn() => throw new Exception()) + ->callback($callback); // to inspect the log-level + + foreach ($initMethodCalls->getCalls() as $methodCall) { + + $method = $methodCall->getMethod(); + $args = $methodCall->getArgs(); + + $toCall = [$control, $method]; + if (is_callable($toCall)) { + /** @var Control $control */ + $control = call_user_func_array($toCall, $args); + } else { + throw new Exception("Can't call method $method on class Control"); + } + } + + if (Environment::isLaravel()) { + // the only way to actually change the log reporting level is to update app/Exceptions/Handler.php + // otherwise it's reported as "error" + self::logShouldReceive(Settings::REPORTING_LEVEL_ERROR); + } else { + throw new Exception('Log checking needs to be updated for the current framework'); + } + + $control->execute(); + } + + /** + * Provide data for the test_the_log_levels test. + * + * @return array> + */ + public static function logLevelDataProvider(): array + { + $return = []; + + // call ->level($logLevel) + foreach (Settings::LOG_LEVELS as $logLevel) { + $return[] = [ + 'initMethodCalls' => MethodCalls::add('level', [$logLevel]), + 'expectedLevel' => $logLevel, + ]; + } + + // call ->debug(), ->info(), …, ->emergency() + foreach (Settings::LOG_LEVELS as $logLevel) { + $return[] = [ + 'initMethodCalls' => MethodCalls::add($logLevel), + 'expectedLevel' => $logLevel, + ]; + } + + return $return; + } + + + + /** + * Assert that the logger should be called once. + * + * @param string $level The log reporting level to check. + * @return void + * @throws Exception When the framework isn't recognised. + */ + private static function logShouldReceive(string $level): void + { + if (!Environment::isLaravel()) { + throw new Exception('Log checking needs to be updated for the current framework'); + } + + Log::shouldReceive($level)->once(); + } + +// /** +// * Assert that the logger should not be called at all. +// * +// * @param string $level The log reporting level to check. +// * @return void +// * @throws Exception When the framework isn't recognised. +// */ +// private static function logShouldNotReceive(string $level): void +// { +// if (!Environment::isLaravel()) { +// throw new Exception('Log checking needs to be updated for the current framework'); +// } +// +// Log::shouldReceive($level)->atMost()->times(0); +// } +} diff --git a/tests/Unit/ControlUnitTest.php b/tests/Unit/ControlUnitTest.php new file mode 100644 index 0000000..b273b82 --- /dev/null +++ b/tests/Unit/ControlUnitTest.php @@ -0,0 +1,2502 @@ +getException($exception); + $exceptionWasThrownUponInit = false; + try { + foreach ($initMethodCalls->getCalls() as $methodCall) { + + $method = $methodCall->getMethod(); + $args = $methodCall->getArgs(); + + // place the exception callback into the args for calls to callback() / callbacks() + foreach ($args as $index => $arg) { + if ((in_array($method, ['callback', 'callbacks'])) && ($arg ?? null)) { + + $exceptionCallbackRunCount[$exceptionCallbackCount] = 0; + + $args[$index] = function () use ( + &$exceptionCallbackWasRun, + &$exceptionCallbackRunCount, + $exceptionCallbackCount, + ) { + $exceptionCallbackWasRun = true; + $exceptionCallbackRunCount[$exceptionCallbackCount]++; + }; + + $exceptionCallbackCount++; + } + } + + $toCall = [$control, $method]; + if (is_callable($toCall)) { + /** @var Control $control */ + $control = call_user_func_array($toCall, $args); + } else { + throw new Exception("Can't call method $method on class Control"); + } + } + } catch (Throwable $e) { +// dump("Exception: \"{$e->getMessage()}\" in {$e->getFile()}:{$e->getLine()}"); + $exceptionWasThrownUponInit = true; + } + + self::assertSame($expectExceptionUponInit, $exceptionWasThrownUponInit); + if ($exceptionWasThrownUponInit) { + return; + } + + + + // Note: the actual level used is handled by the app/Exceptions/Handler.php + // in Laravel, it's logged as error unless updated + $expectExceptionToBeLogged + ? self::logShouldReceive(Settings::REPORTING_LEVEL_ERROR) + : self::logShouldNotReceive(Settings::REPORTING_LEVEL_ERROR); + + + + // run the closure + $exceptionWasDetectedOutside = false; + $returnValue = null; + try { + $returnValue = $control->execute(); + } catch (Throwable $e) { +// dump("Exception: \"{$e->getMessage()}\" in {$e->getFile()}:{$e->getLine()}"); + if ($e === $theExceptionToThrow) { + $exceptionWasDetectedOutside = true; + } + } + + + + self::assertSame(1, $closureRunCount); + + self::assertSame($expectCallbackToBeRun, $exceptionCallbackWasRun); + for ($count = 0; $count < $exceptionCallbackCount; $count++) { + $expected = $expectCallbackToBeRun + ? 1 + : 0; + self::assertSame($expected, $exceptionCallbackRunCount[$count]); + } + + self::assertSame($expectExceptionThrownToCaller, $exceptionWasDetectedOutside); + + $expectedReturn = is_null($exceptionToTrigger) + ? $intendedReturnValue + : $defaultReturn; + self::assertSame($expectedReturn, $returnValue); + + if ($exceptionToTrigger) { + self::assertInstanceOf($exceptionToTrigger, $exception); + } else { + self::assertNull($exception); + } + } + + + + + + /** + * DataProvider for test_that_control_method_calls_operate_properly(). + * + * Provide the different combinations of how the Control object can be set up and called. + * + * @return array> + */ + public static function controlMethodCallsDataProvider(): array + { + $catchCombinations = [ + null, // don't call + [Throwable::class], + [InvalidArgumentException::class], + [DivisionByZeroError::class], +// [[Throwable::class, DivisionByZeroError::class]], +// [[Throwable::class, InvalidArgumentException::class]], +// [Throwable::class, DivisionByZeroError::class], +// [Throwable::class, InvalidArgumentException::class], + ]; + + $matchCombinations = [ + null, // don't call + [self::$exceptionMessage], + ['(NO MATCH)'], + ]; + + $matchRegexCombinations = [ + null, // don't call + ['/Something/'], + ['(NO MATCH)'], + ]; + + $callbackCombinations = [ + null, // don't call + [true], // is replaced with the callback later, inside the test + ]; + + $knownCombinations = [ + null, // don't call + ['ABC'], +// [['ABC', 'DEF']], + ]; + + $channelCombinations = [ + null, // don't call + ['stack'], + ]; + + $levelCombinations = [ + null, // don't call + ['info'], +// ['BLAH'], // error + ]; + + $reportCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $rethrowCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $suppressCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $triggerExceptionTypes = [ + null, // don't throw an exception + Exception::class, + InvalidArgumentException::class, + ]; + + + $return = []; + + foreach ($triggerExceptionTypes as $exceptionToTrigger) { + + self::$currentExceptionToTrigger = $exceptionToTrigger; + +// foreach ($channelCombinations as $channel) { +// foreach ($levelCombinations as $level) { +// foreach ($knownCombinations as $known) { + foreach ($catchCombinations as $catch) { + foreach ($matchCombinations as $match) { + foreach ($matchRegexCombinations as $matchRegex) { + foreach ($callbackCombinations as $callback) { + foreach ($reportCombinations as $report) { + foreach ($rethrowCombinations as $rethrow) { + foreach ($suppressCombinations as $suppress) { + + $initMethodCalls = MethodCalls::new() + ->add('catch', $catch) + ->add('match', $match) + ->add('matchRegex', $matchRegex) + ->add('callback', $callback) +// ->add('known', $known) +// ->add('channel', $channel) +// ->add('level', $level) + ->add('report', $report) +// ->add('dontReport', $dontReport) + ->add('rethrow', $rethrow) +// ->add('dontRethrow', $dontRethrow) +// ->add('execute', $execute) + ->add('suppress', $suppress) + ; + + $return[] = self::buildParams($initMethodCalls); + } + } + } + } + } + } + } + + + + $return[] = self::buildParams(MethodCalls::add('catch')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('catch', [Exception::class]) + ->add('catch', [Throwable::class, DivisionByZeroError::class]) + ->add('catch', [[Exception::class, DivisionByZeroError::class]])); + + + + $return[] = self::buildParams(MethodCalls::add('match')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('match', ['Blah1']) + ->add('match', ['Blah2', 'Blah3']) + ->add('match', [['Blah2', 'Blah4']])); + + $return[] = self::buildParams(MethodCalls::add('matchRegex')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('matchRegex', ['/^Blah1$/']) + ->add('matchRegex', ['/^Blah2$/', '/^Blah3$/']) + ->add('matchRegex', [['/^Blah2$/', '/^Blah4$/']])); + + + + $return[] = self::buildParams(MethodCalls::add('callback')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('callback', [true]) + ->add('callback', [true]) + ->add('callback', [true])); + $return[] = self::buildParams(MethodCalls::add('callbacks')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('callbacks', [true]) + ->add('callbacks', [true, true]) + ->add('callbacks', [[true, true]]) + ->add('callback', [[true]])); + + foreach ([true, false] as $report) { + foreach ([true, false] as $rethrow) { + + $methodCalls = MethodCalls::add('callback', [true]) + ->add('report', [$report]) + ->add('rethrow', [$rethrow]); + + $return[] = self::buildParams($methodCalls); + } + } + + + + $return[] = self::buildParams(MethodCalls::add('known')); // error - no params + $return[] = self::buildParams(MethodCalls::add('known', ['ABC'])); + $return[] = self::buildParams(MethodCalls::add('known', ['ABC', 'DEF'])); + $return[] = self::buildParams(MethodCalls::add('known', [['ABC', 'DEF']])); + $return[] = self::buildParams(MethodCalls::add('known', [['ABC', 'DEF'], 'GHI'])); + $return[] = self::buildParams(MethodCalls + ::add('known', ['ABC',]) + ->add('known', ['DEF']) + ->add('known', ['ABC'])); + $return[] = self::buildParams(MethodCalls + ::add('known', ['ABC',]) + ->add('known', ['DEF', 'GHI']) + ->add('known', [['JKL', 'GHI']]) + ->add('known', [['JKL', 'GHI'], 'MNO'])); + + + + $return[] = self::buildParams(MethodCalls::add('channel')); // error - no params + $return[] = self::buildParams(MethodCalls::add('channel', ['a'])); + $return[] = self::buildParams(MethodCalls::add('channels', ['a'])); + $return[] = self::buildParams(MethodCalls::add('channels', ['a', 'b'])); + $return[] = self::buildParams(MethodCalls::add('channels', [['a', 'b']])); + $return[] = self::buildParams(MethodCalls::add('channels', [['a', 'b'], 'c'])); + $return[] = self::buildParams(MethodCalls + ::add('channel', ['a']) + ->add('channel', ['b']) + ->add('channel', ['a'])); + $return[] = self::buildParams(MethodCalls::add('channels')); // error - no params + $return[] = self::buildParams(MethodCalls + ::add('channels', ['a']) + ->add('channels', ['b', 'c']) + ->add('channels', [['d', 'c']]) + ->add('channels', [['d', 'c'], 'z'])); + + + + $return[] = self::buildParams(MethodCalls::add('level')); // error - no params + $return[] = self::buildParams(MethodCalls::add('level', ['BLAH'])); // error - invalid level + $return[] = self::buildParams(MethodCalls + ::add('level', [Settings::REPORTING_LEVEL_INFO]) + ->add('level', [Settings::REPORTING_LEVEL_WARNING]) + ->add('level', [Settings::REPORTING_LEVEL_INFO])); + foreach (Settings::LOG_LEVELS as $level) { + $return[] = self::buildParams(MethodCalls::add('level', [$level])); + } + + + + foreach (Settings::LOG_LEVELS as $level) { + $return[] = self::buildParams(MethodCalls::add($level)); + } + + + + $return[] = self::buildParams(MethodCalls::add('report')); + $return[] = self::buildParams(MethodCalls::add('report', [true])); + $return[] = self::buildParams(MethodCalls::add('report', [false])); + $return[] = self::buildParams(MethodCalls::add('dontReport')); + $return[] = self::buildParams(MethodCalls::add('report')->add('dontReport')); + $return[] = self::buildParams(MethodCalls::add('report')->add('dontReport')->add('report')); + + + + $return[] = self::buildParams(MethodCalls::add('rethrow')); + $return[] = self::buildParams(MethodCalls::add('rethrow', [true])); + $return[] = self::buildParams(MethodCalls::add('rethrow', [false])); + $return[] = self::buildParams(MethodCalls::add('rethrow', [fn(Throwable $exception) => true])); + $return[] = self::buildParams(MethodCalls::add('dontRethrow')); + $return[] = self::buildParams(MethodCalls::add('rethrow')->add('dontRethrow')); + $return[] = self::buildParams( + MethodCalls + ::add('rethrow', [fn(Throwable $exception) => true]) + ->add('dontRethrow') + ->add('rethrow') + ); + $return[] = self::buildParams( + MethodCalls + ::add('rethrow', [fn(Throwable $exception) => true]) + ->add('dontRethrow') + ->add('rethrow', [fn(Throwable $exception) => true]) + ); + + + + $return[] = self::buildParams( + MethodCalls + ::add('report') + ->add('rethrow') + ->add('suppress') + ); + + + + // no callbacks, don't report, but rethrow + // causes the $context not to be built, and the exception re-thrown straight away + $return[] = self::buildParams(MethodCalls::add('dontReport')->add('rethrow')); + + + + $return[] = self::buildParams(MethodCalls::add('default', ['abc'])); + + + + // test more catch combinations + $possibleCatchArgs = [ + Throwable::class, + InvalidArgumentException::class, + DivisionByZeroError::class, + new CatchType(), + CatchType::catch(Throwable::class), + CatchType::catch(DivisionByZeroError::class), + ]; + + $catchCombinations2 = []; + $catchCombinations2[] = null; // don't call + foreach ($possibleCatchArgs as $catchArg1) { + $catchCombinations2[] = [$catchArg1]; + foreach ($possibleCatchArgs as $catchArg2) { + if ($catchArg1 !== $catchArg2) { + $catchCombinations2[] = [$catchArg1, $catchArg2]; + } + } + } + + foreach ($catchCombinations2 as $catch) { + foreach ($matchCombinations as $match) { + $initMethodCalls = MethodCalls::add('catch', $catch)->add('match', $match); + $return[] = self::buildParams($initMethodCalls); + } + } + } + + return $return; + } + + /** + * Determine the parameters to pass to the test_that_control_method_calls_operate_properly() test. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the Control object. + * @return array + */ + private static function buildParams(MethodCalls $initMethodCalls): array + { + + $expectExceptionUponInit = self::willExceptionBeThrownUponInit($initMethodCalls); + $fallbackCalls = self::buildFallbackCalls($initMethodCalls); + + $willBeCaughtBy = null; + if (!$expectExceptionUponInit) { + $catchTypes = self::pickCatchTypes($initMethodCalls); + $willBeCaughtBy = self::determineWhatWillCatchTheException( + self::$currentExceptionToTrigger, + $fallbackCalls, + $catchTypes + ); + } + + return [ + 'initMethodCalls' => $initMethodCalls, + 'exceptionToTrigger' => self::$currentExceptionToTrigger, + 'expectExceptionUponInit' => $expectExceptionUponInit, + 'expectCallbackToBeRun' => self::determineIfCallbacksWillBeRun( + $willBeCaughtBy, + $initMethodCalls, + ), + 'expectExceptionToBeLogged' => self::determineIfExceptionWillBeLogged( + $willBeCaughtBy, + $fallbackCalls, + ), + 'expectExceptionThrownToCaller' => self::willExceptionBeThrownToCaller( + self::$currentExceptionToTrigger, + $willBeCaughtBy, + $fallbackCalls, + ), + 'defaultReturn' => self::pickDefaultReturnValue($willBeCaughtBy, $initMethodCalls), + ]; + } + + /** + * Build the method calls that build the fallback object. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the Control object. + * @return MethodCalls + */ + private static function buildFallbackCalls(MethodCalls $initMethodCalls): MethodCalls + { + $fallbackCalls = new MethodCalls(); + foreach ($initMethodCalls->getCalls() as $methodCall) { + + if ($methodCall->getMethod() == 'catch') { + + // use when not a CatchType + $args = $methodCall->getArgsFlat(fn($a) => !$a instanceof CatchType); + if (count($args)) { + $fallbackCalls->add($methodCall->getMethod(), $args); + } + + } else { + $fallbackCalls->add($methodCall->getMethod(), $methodCall->getArgs()); + } + } + + return $fallbackCalls; + } + + /** + * Pick the already built CatchType objects from the initialisation calls. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the Control object. + * @return CatchType[] + */ + private static function pickCatchTypes(MethodCalls $initMethodCalls): array + { + /** @var CatchType[] $args */ + $args = $initMethodCalls->getAllCallArgsFlat('catch', fn($arg) => $arg instanceof CatchType); + return $args; + } + + + + /** + * Determine if a thrown exception will be caught. + * + * @param string|null $exceptionToTrigger The exception type to trigger (if any). + * @param MethodCalls $fallbackCalls The method calls that build the fallback object. + * @param CatchType[] $catchTypes The already built CatchType objects from the initialisation calls. + * @return CatchType|MethodCalls|null + */ + private static function determineWhatWillCatchTheException( + ?string $exceptionToTrigger, + MethodCalls $fallbackCalls, + array $catchTypes + ): CatchType|MethodCalls|null { + + if (is_null($exceptionToTrigger)) { + return null; + } + + + + // check each CatchType first + foreach ($catchTypes as $catchType) { + if (self::wouldCatchTypeCatch($exceptionToTrigger, $catchType, $fallbackCalls)) { + return $catchType; + } + } + + // if there are CatchTypes, and the fall-back doesn't define class/es to catch, then stop + if ((count($catchTypes)) && (!$fallbackCalls->hasCall('catch'))) { + return null; + } + + // check the fallback settings second + /** @var string[] $fallbackCatchClasses */ + $fallbackCatchClasses = $fallbackCalls->getAllCallArgsFlat('catch'); + if (!self::checkIfExceptionClassesMatch($exceptionToTrigger, $fallbackCatchClasses)) { + return null; + } + /** @var string[] $fallbackMatchStrings */ + $fallbackMatchStrings = $fallbackCalls->getAllCallArgsFlat('match'); + /** @var string[] $fallbackMatchRegexes */ + $fallbackMatchRegexes = $fallbackCalls->getAllCallArgsFlat('matchRegex'); + $a = self::checkIfMatchesMatch($fallbackMatchStrings); + $b = self::checkIfRegexesMatch($fallbackMatchRegexes); + + if (($a === false || $b === false) && $a !== true && $b !== true) { + return null; + } + return $fallbackCalls; + } + + /** + * Check if a given CatchType would catch an exception. + * + * @param string $exceptionToTrigger The exception type to trigger (if any). + * @param CatchType $catchType The CatchType to check. + * @param MethodCalls $fallbackCalls The method calls that build the fallback object. + * @return boolean + */ + private static function wouldCatchTypeCatch( + string $exceptionToTrigger, + CatchType $catchType, + MethodCalls $fallbackCalls + ): bool { + + $inspector = new Inspector($catchType); + + if (!self::checkIfExceptionClassesMatch($exceptionToTrigger, $inspector->getExceptionClasses())) { + return false; + } + + /** @var string[] $fallbackMatchStrings */ + $fallbackMatchStrings = $fallbackCalls->getAllCallArgsFlat('match'); + /** @var string[] $fallbackMatchRegexes */ + $fallbackMatchRegexes = $fallbackCalls->getAllCallArgsFlat('matchRegex'); + + $matchStrings = $inspector->getRawMatchStrings() ?: $fallbackMatchStrings; + $matchRegexes = $inspector->getRawMatchRegexes() ?: $fallbackMatchRegexes; + + $a = self::checkIfMatchesMatch($matchStrings); + $b = self::checkIfRegexesMatch($matchRegexes); + if (($a === false || $b === false) && $a !== true && $b !== true) { + return false; + } + + return true; + } + + /** + * Check if an array of exception classes match the exception type. + * + * @param string $exceptionToTrigger The exception type that will be triggered. + * @param string[] $exceptionClasses The exception types to catch. + * @return boolean + */ + private static function checkIfExceptionClassesMatch(string $exceptionToTrigger, array $exceptionClasses): bool + { + if (!count($exceptionClasses)) { + return true; // implies that all exceptions should be caught + } + if (in_array(Throwable::class, $exceptionClasses)) { + return true; + } + if (in_array($exceptionToTrigger, $exceptionClasses)) { + return true; + } + return false; + } + + /** + * Check if an array of match strings would match the exception message. + * + * @param string[] $matchStrings The matches to try. + * @return boolean|null + */ + private static function checkIfMatchesMatch(array $matchStrings): ?bool + { + if (!count($matchStrings)) { + return null; + } + + return in_array(self::$exceptionMessage, $matchStrings); + } + + /** + * Check if an array of regexes would match the exception message. + * + * @param string[] $matchRegexes The matches to try. + * @return boolean|null + */ + private static function checkIfRegexesMatch(array $matchRegexes): ?bool + { + if (!count($matchRegexes)) { + return null; + } + + foreach ($matchRegexes as $regex) { + if (preg_match($regex, self::$exceptionMessage)) { + return true; + } + } + return false; + } + + + + /** + * Determine if an exception will be triggered when setting up the Control instance. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the Control object. + * @return boolean + */ + private static function willExceptionBeThrownUponInit(MethodCalls $initMethodCalls): bool + { + // check the "level" arguments + $fallbackLevels = collect($initMethodCalls->getCalls('level')) + ->map(fn(MethodCall $m) => $m->getArgsFlat()) + ->flatten(1) + ->toArray(); + /** @var string|null $lastFallbackLevel */ + $lastFallbackLevel = collect($fallbackLevels)->last(); + + /** @var CatchType[] $catchTypes */ + $catchTypes = $initMethodCalls->getAllCallArgsFlat('catch', fn($arg) => $arg instanceof CatchType); + $catchTypeLevels = collect($catchTypes) + ->map(fn(CatchType $c) => new Inspector($c)) + ->map(fn(Inspector $c) => $c->getRawLevel() ?? $lastFallbackLevel) + ->filter(fn(?string $level) => is_string($level)) + ->toArray(); + + /** @var array $allLevels */ + $allLevels = array_merge($fallbackLevels, $catchTypeLevels); + $allLevels = array_filter($allLevels, fn(?string $level) => !is_null($level)); // remove nulls + + foreach ($allLevels as $arg) { + if (!in_array($arg, Settings::LOG_LEVELS)) { + return true; // init error + } + } + + + + // check the number of parameters that the calls have + $methodsAllowedToHaveNoParameters = [ + 'report', + 'dontReport', + 'rethrow', + 'dontRethrow', + 'execute', + 'suppress', + Settings::REPORTING_LEVEL_DEBUG, + Settings::REPORTING_LEVEL_INFO, + Settings::REPORTING_LEVEL_NOTICE, + Settings::REPORTING_LEVEL_WARNING, + Settings::REPORTING_LEVEL_ERROR, + Settings::REPORTING_LEVEL_CRITICAL, + Settings::REPORTING_LEVEL_ALERT, + Settings::REPORTING_LEVEL_EMERGENCY, + ]; + foreach ($initMethodCalls->getCalls() as $methodCall) { + // allowed to be called with no parameters + if (in_array($methodCall->getMethod(), $methodsAllowedToHaveNoParameters)) { + continue; + } + // NOT allowed to be called without parameters + if (!count($methodCall->getArgs())) { + return true; // init error + } + } + + return false; + } + + /** + * Determine if an exception will be logged. + * + * @param CatchType|MethodCalls|null $willBeCaughtBy The CatchType (or array of fallbackArgs) that catch the + * exception. + * @param MethodCalls $initMethodCalls The method calls that build the fallback object. + * @return boolean + */ + private static function determineIfCallbacksWillBeRun( + CatchType|MethodCalls|null $willBeCaughtBy, + MethodCalls $initMethodCalls + ): bool { + + if (!$willBeCaughtBy) { + return false; + } + if (!$initMethodCalls->hasCall('callback')) { + return false; + } + + $report = true; // default value + $rethrow = false; // default value + $callsList = ['report', 'dontReport', 'rethrow', 'dontRethrow', 'suppress']; + foreach ($initMethodCalls->getCalls($callsList) as $methodCall) { + /** @var 'report'|'dontReport'|'rethrow'|'dontRethrow'|'suppress' $method */ + $method = $methodCall->getMethod(); + match ($method) { + 'report' => $report = (bool) ($methodCall->getArgs()[0] ?? true), + 'dontReport' => $report = false, + 'rethrow' => $rethrow = (bool) ($methodCall->getArgs()[0] ?? true), + 'dontRethrow' => $rethrow = false, + 'suppress' => $report = $rethrow = false, + }; + } + + if ((!$report) && (!$rethrow)) { + return false; + } + + return true; + } + + /** + * Determine if an exception will be logged. + * + * @param CatchType|MethodCalls|null $willBeCaughtBy The CatchType (or array of fallbackArgs) that catch the + * exception. + * @param MethodCalls $fallbackCalls The method calls that build the fallback object. + * @return boolean + */ + private static function determineIfExceptionWillBeLogged( + CatchType|MethodCalls|null $willBeCaughtBy, + MethodCalls $fallbackCalls, + ): bool { + + if (!$willBeCaughtBy) { + return false; + } + + // what would the fall-back settings do + $fallbackReport = null; + foreach ($fallbackCalls->getCalls(['report', 'dontReport', 'suppress']) as $methodCall) { + /** @var 'report'|'dontReport'|'suppress' $method */ + $method = $methodCall->getMethod(); + $fallbackReport = match ($method) { + 'report' => (bool) ($methodCall->getArgs()[0] ?? true), + 'dontReport', 'suppress' => false, + }; + } + + $defaultReport = true; // default true + + // if it's a CatchType that catches the exception + if ($willBeCaughtBy instanceof CatchType) { + $inspector = new Inspector($willBeCaughtBy); + return $inspector->getRawReport() ?? $fallbackReport ?? $defaultReport; + } + + // or if it's the fallback that catches the exception + return $fallbackReport ?? $defaultReport; + } + + /** + * Determine if a thrown exception should be detected by the calling code. + * + * @param string|null $exceptionToTrigger The exception type to trigger (if any). + * @param CatchType|MethodCalls|null $willBeCaughtBy The CatchType (or array of fallbackArgs) that catch the + * exception. + * @param MethodCalls $fallbackCalls The method calls that build the fallback object. + * @return boolean + */ + private static function willExceptionBeThrownToCaller( + ?string $exceptionToTrigger, + CatchType|MethodCalls|null $willBeCaughtBy, + MethodCalls $fallbackCalls, + ): bool { + + if (!$exceptionToTrigger) { + return false; + } + + if (!$willBeCaughtBy) { + return true; + } + + + + // what would the fall-back settings do + $fallbackRethrow = null; + foreach ($fallbackCalls->getCalls(['rethrow', 'dontRethrow', 'suppress']) as $methodCall) { + /** @var 'rethrow'|'dontRethrow'|'suppress' $method */ + $method = $methodCall->getMethod(); + $fallbackRethrow = match ($method) { + 'rethrow' => (bool) ($methodCall->getArgs()[0] ?? true), + 'dontRethrow', 'suppress' => false, + }; + } + + $defaultRethrow = false; // default false + + // if it's a CatchType that catches the exception + if ($willBeCaughtBy instanceof CatchType) { + $inspector = new Inspector($willBeCaughtBy); + $return = $inspector->getRawRethrow() ?? $fallbackRethrow ?? $defaultRethrow; + return is_callable($return) ? true : $return; // pretend that any callable will return true + } + + // or if it's the fallback that catches the exception + return $fallbackRethrow ?? $defaultRethrow; + } + + /** + * Determine the default return value that's used. + * + * @param CatchType|MethodCalls|null $willBeCaughtBy The CatchType (or array of fallbackArgs) that catch the + * exception. + * @param MethodCalls $fallbackCalls The method calls that build the fallback object. + * @return mixed + */ + private static function pickDefaultReturnValue( + CatchType|MethodCalls|null $willBeCaughtBy, + MethodCalls $fallbackCalls, + ): mixed { + + // what would the fall-back settings do + $fallbackDefault = null; + foreach ($fallbackCalls->getCalls(['default']) as $methodCall) { + $fallbackDefault = $methodCall->getArgs()[0] ?? null; + } + + // if it's a CatchType that catches the exception + if ($willBeCaughtBy instanceof CatchType) { + $inspector = new Inspector($willBeCaughtBy); + return $inspector->getRawDefault() ?? $fallbackDefault; + } + + // or if it's the fallback that catches the exception + return $fallbackDefault; + } + + + + /** + * Test that the Control object's methods set the log-levels properly. + * + * @test + * @dataProvider logLevelDataProvider + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the Control object. + * @param string $expectedLevel The log reporting level to expect. + * @return void + * @throws Exception When an initialisation method can't be called. + */ + public static function test_the_log_levels(MethodCalls $initMethodCalls, string $expectedLevel): void + { + $callback = fn(Context $context) => self::assertSame($expectedLevel, $context->getLevel()); + + // initialise the Control object + $control = Control::prepare(self::throwExceptionClosure()) + ->callback($callback); // to inspect the log-level + + foreach ($initMethodCalls->getCalls() as $methodCall) { + + $method = $methodCall->getMethod(); + $args = $methodCall->getArgs(); + + $toCall = [$control, $method]; + if (is_callable($toCall)) { + /** @var Control $control */ + $control = call_user_func_array($toCall, $args); + } else { + throw new Exception("Can't call method $method on class Control"); + } + } + + if (Environment::isLaravel()) { + // the only way to actually change the log reporting level is to update app/Exceptions/Handler.php + // otherwise it's reported as "error" + self::logShouldReceive(Settings::REPORTING_LEVEL_ERROR); + } else { + throw new Exception('Log checking needs to be updated for the current framework'); + } + + $control->execute(); + } + + /** + * Provide data for the test_the_log_levels test. + * + * @return array> + */ + public static function logLevelDataProvider(): array + { + $return = []; + + // call ->level($logLevel) + foreach (Settings::LOG_LEVELS as $logLevel) { + $return[] = [ + 'initMethodCalls' => MethodCalls::add('level', [$logLevel]), + 'expectedLevel' => $logLevel, + ]; + } + + // call ->debug(), ->info(), …, ->emergency() + foreach (Settings::LOG_LEVELS as $logLevel) { + $return[] = [ + 'initMethodCalls' => MethodCalls::add($logLevel), + 'expectedLevel' => $logLevel, + ]; + } + + return $return; + } + + + + /** + * Test that the order the CatchTypes are defined in, matters. + * + * @test + * + * @return void + * @throws Exception Exceptions that weren't supposed to be caught. + */ + public static function test_that_the_catch_type_order_matters(): void + { + $callback1 = function () use (&$callback1Ran) { + $callback1Ran = true; + }; + $callback2 = function () use (&$callback2Ran) { + $callback2Ran = true; + }; + $callback3 = function () use (&$callback3Ran) { + $callback3Ran = true; + }; + + $catchType1 = CatchType::catch(Exception::class)->callback($callback1); + $catchType2 = CatchType::catch(Exception::class)->callback($callback2); + $catchType3 = CatchType::catch(Exception::class)->callback($callback3); + + + + $callback1Ran = $callback2Ran = $callback3Ran = false; + Control::prepare(self::throwExceptionClosure()) + ->catch($catchType1) + ->catch($catchType2) + ->catch($catchType3) + ->execute(); + + self::assertTrue($callback1Ran); + self::assertFalse($callback2Ran); + self::assertFalse($callback3Ran); + + + + $callback1Ran = $callback2Ran = $callback3Ran = false; + Control::prepare(self::throwExceptionClosure()) + ->catch($catchType2) + ->catch($catchType3) + ->catch($catchType1) + ->execute(); + + self::assertFalse($callback1Ran); + self::assertTrue($callback2Ran); + self::assertFalse($callback3Ran); + + + + $callback1Ran = $callback2Ran = $callback3Ran = false; + Control::prepare(self::throwExceptionClosure()) + ->catch($catchType3) + ->catch($catchType1) + ->catch($catchType2) + ->execute(); + + self::assertFalse($callback1Ran); + self::assertFalse($callback2Ran); + self::assertTrue($callback3Ran); + } + + + + /** + * Test that the correct parameters are passed to callbacks (via dependency injection). + * + * @test + * @dataProvider callbackParameterDataProvider + * + * @param callable $callback The callback to run. + * @param boolean $expectExceptionToBeRethrown Whether to expect an exception or not. + * @return void + */ + public static function test_callback_parameters(callable $callback, bool $expectExceptionToBeRethrown): void + { + $caughtException = false; + try { + Control::prepare(self::throwExceptionClosure()) + ->callback($callback) + ->execute(); + } catch (Throwable) { + $caughtException = true; + } + + self::assertSame($expectExceptionToBeRethrown, $caughtException); + } + + /** + * DataProvider for test_callback_parameters(). + * + * @return array> + */ + public static function callbackParameterDataProvider(): array + { + // callbacks that don't cause an exception + $callbacks = []; + $callbacks[] = function ($exception) { + self::assertInstanceOf(Exception::class, $exception); + }; + + $callbacks[] = function (Throwable $exception) { + self::assertInstanceOf(Exception::class, $exception); + }; + + $callbacks[] = function ($e) { + self::assertInstanceOf(Exception::class, $e); + }; + + $callbacks[] = function (Throwable $e) { + self::assertInstanceOf(Exception::class, $e); + }; + + $callbacks[] = function (Context $a) { + self::assertTrue(true); // $a will be a Context because of the parameter definition + }; + + $callbacks[] = function (Request $a) { + self::assertTrue(true); // $a will be a Request because of the parameter definition + }; + + $callbacks = collect($callbacks) + ->map(fn(callable $callback) => [$callback, false]) + ->values() + ->toArray(); + + + + // callbacks that cause an exception + $exceptionCallbacks = []; + $exceptionCallbacks[] = function ($a) { + }; + $exceptionCallbacks[] = function (Throwable $throwable) { + }; + + $exceptionCallbacks = collect($exceptionCallbacks) + ->map(fn(callable $callback) => [$callback, true]) + ->values() + ->toArray(); + + + + /** @var array> $return */ + $return = array_merge($callbacks, $exceptionCallbacks); + + return $return; + } + + + + /** + * Test what happens when the callback alters the report and rethrow settings. + * + * @test + * @dataProvider callbackContextEditDataProvider + * + * @param boolean $report The report value the callback should set. + * @param boolean $rethrow The rethrow value the callback should set. + * @param boolean $suppress The value for the callback to return. + * @return void + */ + public static function test_callback_that_updates_the_context_object( + bool $report, + bool $rethrow, + bool $suppress, + ): void { + + $callback1Ran = false; + $callback1 = function (Context $context) use ($report, $rethrow, $suppress, &$callback1Ran) { + $context->setReport($report); + $context->setRethrow($rethrow); + if ($suppress) { + $context->suppress(); + } + $callback1Ran = true; + }; + + $callback2Ran = false; + $callback2 = function () use (&$callback2Ran) { + $callback2Ran = true; + }; + + + + $report && !$suppress + ? self::logShouldReceive(Settings::REPORTING_LEVEL_ERROR) + : self::logShouldNotReceive(Settings::REPORTING_LEVEL_ERROR); + + + + // run the closure + $exceptionWasRethrown = false; + try { + Control::prepare(self::throwExceptionClosure()) + ->callback($callback1) + ->callback($callback2) + ->execute(); + } catch (Throwable) { + $exceptionWasRethrown = true; + } + + + + self::assertSame($rethrow && !$suppress, $exceptionWasRethrown); + self::assertTrue($callback1Ran); + self::assertSame(($report || $rethrow) && !$suppress, $callback2Ran); + } + + /** + * Provide data for the test_callback_that_updates_the_context_object test. + * + * @return array> + */ + public static function callbackContextEditDataProvider(): array + { + return [ + ['report' => false, 'rethrow' => false, 'suppress' => false], + ['report' => true, 'rethrow' => false, 'suppress' => false], + ['report' => false, 'rethrow' => true, 'suppress' => false], + ['report' => true, 'rethrow' => true, 'suppress' => false], + ['report' => false, 'rethrow' => false, 'suppress' => true], + ['report' => true, 'rethrow' => false, 'suppress' => true], + ['report' => false, 'rethrow' => true, 'suppress' => true], + ['report' => true, 'rethrow' => true, 'suppress' => true], + ]; + } + + + + /** + * Test that global callbacks are called. + * + * @test + * + * @return void + */ + public static function test_that_global_callbacks_are_called(): void + { + $order = []; + + $globalCallback1 = function () use (&$order) { + $order[] = 'gc1'; + }; + $globalCallback2 = function () use (&$order) { + $order[] = 'gc2'; + }; + $globalCallback3 = function () use (&$order) { + $order[] = 'gc3'; + }; + $globalCallback4 = function () use (&$order) { + $order[] = 'gc4'; + }; + + $callback1 = function () use (&$order) { + $order[] = 'c1'; + }; + $callback2 = function () use (&$order) { + $order[] = 'c2'; + }; + $callback3 = function () use (&$order) { + $order[] = 'c3'; + }; + $callback4 = function () use (&$order) { + $order[] = 'c4'; + }; + + + + Control::globalCallback($globalCallback1); + Control::prepare(self::throwExceptionClosure()) + ->callback($callback1) + ->execute(); + + Control::globalCallbacks($globalCallback2); + Control::prepare(self::throwExceptionClosure()) + ->callback($callback2) + ->execute(); + + Control::globalCallbacks([$globalCallback3], $globalCallback4); + Control::prepare(self::throwExceptionClosure()) + ->callback($callback3) + ->callback($callback4) + ->execute(); + + self::assertSame(['gc1', 'c1', 'gc1', 'gc2', 'c2', 'gc1', 'gc2', 'gc3', 'gc4', 'c3', 'c4'], $order); + } + + + + /** + * Test that callbacks aren't called when they're not supposed to be. + * + * @test + * @dataProvider callbacksArentRunDataProvider + * + * @param boolean $report Whether to report or not. + * @param boolean $rethrow Whether to rethrow or not. + * @param boolean $expectCallbacksToRun Whether the callbacks should be run or not. + * @return void + */ + public static function test_that_callbacks_arent_run(bool $report, bool $rethrow, bool $expectCallbacksToRun): void + { + $order = []; + $callback1 = function () use (&$order) { + $order[] = 1; + }; + $callback2 = function () use (&$order) { + $order[] = 2; + }; + + Control::globalCallback($callback1); + + try { + Control::prepare(self::throwExceptionClosure()) + ->callback($callback2) + ->report($report) + ->rethrow($rethrow) + ->execute(); + } catch (Throwable) { + } + + if ($expectCallbacksToRun) { + self::assertSame([1, 2], $order); + } else { + self::assertSame([], $order); + } + } + + /** + * DataProvider for test_that_callbacks_arent_run. + * + * @return array> + */ + public static function callbacksArentRunDataProvider(): array + { + $return = []; + foreach ([true, false] as $report) { + foreach ([true, false] as $rethrow) { + $return[] = [ + 'report' => $report, + 'rethrow' => $rethrow, + 'expectCallbacksToRun' => $report || $rethrow, + ]; + } + } + + return $return; + } + + + + /** + * Test that the finally callable is called properly. + * + * @test + * + * @return void + */ + public static function test_finally(): void + { + $noException = fn() => null; + $throwsException = fn() => throw new Exception('test'); + + $finally = function () use (&$finallyWasCalled) { + $finallyWasCalled = true; + }; + $finallyFromCatchType = function () use (&$catchTypeFinallyWasCalled) { + $catchTypeFinallyWasCalled = true; + }; + + $catchTypeNoFinally = new CatchType(); + $catchTypeWithFinally = CatchType::finally($finallyFromCatchType); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::run($noException, 'default', $finally); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::run($throwsException, 'default', $finally); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($noException)->finally($finally)->execute(); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($throwsException)->finally($finally)->execute(); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($noException, 'default', $finally)->execute(); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($throwsException, 'default', $finally)->execute(); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + // with a CatchType + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($noException) + ->catch($catchTypeNoFinally) + ->finally($finally) + ->execute(); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($throwsException) + ->catch($catchTypeWithFinally) + ->finally($finally) + ->execute(); + self::assertSame(false, $finallyWasCalled); + self::assertSame(true, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($noException, 'default', $finally) + ->catch($catchTypeNoFinally) + ->execute(); + self::assertSame(true, $finallyWasCalled); + self::assertSame(false, $catchTypeFinallyWasCalled); + + + + $finallyWasCalled = $catchTypeFinallyWasCalled = false; + Control::prepare($throwsException, 'default', $finally) + ->catch($catchTypeWithFinally) + ->execute(); + self::assertSame(false, $finallyWasCalled); + self::assertSame(true, $catchTypeFinallyWasCalled); + } + + + + /** + * Test that closure is run using dependency injection. + * + * @test + * + * @return void + */ + public static function test_that_closure_is_called_using_dependency_injection(): void + { + $closure = fn(Request $request) => self::assertInstanceOf(Request::class, $request); + Control::run($closure); + } + + + + /** + * Test that the "finally" callback is run using dependency injection. + * + * @test + * + * @return void + */ + public static function test_that_finally_is_called_using_dependency_injection(): void + { + $closure = fn(Request $request) => true; + $finally = fn(Request $request) => self::assertInstanceOf(Request::class, $request); + Control::run($closure, null, $finally); + } + + + + /** + * Test that the default values are set and returned properly. + * + * @test + * + * @return void + */ + public static function test_default_values_and_catch_types(): void + { + $throwException = self::throwExceptionClosure(); + + // no default + $return = Control::run($throwException); + self::assertNull($return); + + // default + $return = Control::prepare($throwException) + ->default('control-default') + ->execute(); + self::assertSame('control-default', $return); + + // with CatchType (that has a default) + $return = Control::prepare($throwException) + ->catch(CatchType::default('catch-type-default')) + ->execute(); + self::assertSame('catch-type-default', $return); + + // with top-level default and a CatchType (that catches the exception and has a default) + $return = Control::prepare($throwException) + ->catch(CatchType::default('catch-type-default')) + ->default('control-default') + ->execute(); + self::assertSame('catch-type-default', $return); + + // with top-level default and a CatchType (that doesn't catch the exception) + $return = Control::prepare($throwException) + ->catch(new CatchType()) + ->default('control-default') + ->execute(); + self::assertSame('control-default', $return); + + // no exception + $return = Control::prepare(fn() => 'success', $return) + ->default('control-default') + ->execute(); + self::assertSame('success', $return); + + // callable default + $return = Control::prepare($throwException) + ->default(fn() => 'callable-default') // check that a callable default value is executed + ->execute(); + self::assertSame('callable-default', $return); + + // with a callback that changes the default + $return = Control::prepare($throwException) + ->default('control-default') + ->callback(fn(Context $context) => $context->setDefault('callback-default')) + ->execute(); + self::assertSame('callback-default', $return); + + // with a callback that changes the default to a callable + $return = Control::prepare($throwException) + ->default('control-default') + ->callback(fn(Context $context) => $context->setDefault(fn() => 'callback-default')) + ->execute(); + self::assertSame('callback-default', $return); + + // with a callback that doesn't change the default + $return = Control::prepare($throwException) + ->default('control-default') + ->callback(fn(Context $context) => true) + ->execute(); + self::assertSame('control-default', $return); + } + + + + /** + * Test that the default value is used. + * + * @test + * + * @return void + */ + public static function test_the_different_ways_the_default_value_can_be_set(): void + { + $default = mt_rand(); + $return = Control::run(self::throwExceptionClosure(), $default); + self::assertSame($default, $return); + + $default = mt_rand(); + $return = Control::prepare(self::throwExceptionClosure(), $default)->execute(); + self::assertSame($default, $return); + + $default = mt_rand(); + $return = Control::prepare(self::throwExceptionClosure())->default($default)->execute(); + self::assertSame($default, $return); + + $default1 = mt_rand(); + $default2 = mt_rand(); + $return = Control::prepare(self::throwExceptionClosure(), $default1)->default($default2)->execute(); + self::assertSame($default2, $return); + } + + + + /** + * Test that calling execute returns the same value each time. + * + * @test + * + * @return void + */ + public static function test_that_execute_runs_the_callable_each_time(): void + { + $runCount = 0; + $closure = function () use (&$runCount) { + $runCount++; + return 'abc'; + }; + $control = Control::prepare($closure); + + self::assertSame('abc', $control->execute()); + self::assertSame(1, $runCount); + + self::assertSame('abc', $control->execute()); + self::assertSame(2, $runCount); + } + + + + /** + * Test that nested Control objects are captured when the inner one rethrows the exception. + * + * @test + * + * @return void + */ + public static function test_nested_control_objects(): void + { + $line = __LINE__; + $inspectContext = function (Context $context) use ($line) { + + $callMetaObjects = $context->getCallstack()->getMeta(CallMeta::class); + + // that it has 2 meta objects + self::assertSame(2, count($callMetaObjects)); + + // and that the first meta object… + self::assertSame(__FILE__, $callMetaObjects[0]->getFile()); + self::assertSame($line + 23, $callMetaObjects[0]->getLine()); + + // is different to the second meta object + self::assertSame(__FILE__, $callMetaObjects[1]->getFile()); + self::assertSame($line + 19, $callMetaObjects[1]->getLine()); + }; + + $closure1 = fn() => Control::prepare(self::throwExceptionClosure()) + ->rethrow() + ->execute(); + + Control::prepare($closure1) + ->callback($inspectContext) + ->execute(); + } + + + + /** + * Test that the "known" values are detected properly from nested executions. + * + * @test + * + * @return void + */ + public static function test_that_known_values_of_nested_executions_work(): void + { + $inspectContext = function (Context $context) { + + $callStack = $context->getCallStack(); + + // collect the "known" details from each frame's Meta objects + $allKnown = []; + /** @var Frame $frame */ + foreach ($callStack as $frame) { + /** @var CallMeta $meta */ + foreach ($frame->getMeta(CallMeta::class) as $meta) { + $allKnown = array_merge($allKnown, $meta->getKnown()); + } + } + + // compare them to the "known" details added to the Context object directly + self::assertSame($allKnown, $context->getKnown()); + self::assertSame((bool) count($allKnown), $context->hasKnown()); + + self::assertSame(2, count($allKnown)); + self::assertSame('known 1', $allKnown[0]); + self::assertSame('known 2', $allKnown[1]); + + // check the "known" details by obtaining the CallMeta objects directly + /** @var CallMeta[] $meta */ + $meta = $callStack->getMeta(CallMeta::class); + self::assertSame(['known 1'], $meta[1]->getKnown()); + self::assertSame(['known 2'], $meta[2]->getKnown()); + }; + + $closure2 = fn() => Control::prepare(self::throwExceptionClosure()) + ->known('known 2') + ->rethrow() + ->execute(); + + $closure1 = fn() => Control::prepare($closure2) + ->known('known 1') + ->callback($inspectContext) + ->execute(); + + Clarity::context('context'); + + Control::prepare($closure1) + ->known('known-root') + ->rethrow() + ->execute(); + } + + + + /** + * Test retrieval of the Context object. + * + * @test + * + * @return void + */ + public function test_get_content(): void + { + $callback = function ($e, Context $context) { + self::assertInstanceOf(Context::class, Clarity::getExceptionContext($e)); + self::assertSame($context, Clarity::getExceptionContext($e)); + + self::assertInstanceOf(Context::class, ContextAPI::getLatestExceptionContext()); + self::assertSame($context, ContextAPI::getLatestExceptionContext()); + + $e2 = new Exception('test'); + $newContext = Clarity::getExceptionContext($e2); + self::assertInstanceOf(Context::class, $newContext); + self::assertNotSame($context, $newContext); + }; + + Control::prepare(self::throwExceptionClosure()) + ->callback($callback) + ->execute(); + } + + + + /** + * Test that the prepare() and then execute() method calls work. + * + * @test + * + * @return void + */ + public static function test_prepare_then_execute_methods(): void + { + self::assertSame('a', Control::prepare(fn() => 'a')->execute()); + } + + + + /** + * Test that the run method works. + * + * @test + * + * @return void + */ + public static function test_the_run_method(): void + { + self::assertSame('a', Control::run(fn() => 'a')); + } + + + + /** + * Test that initialisation exceptions are generated properly. + * + * @test + * + * @return void + */ + public static function test_init_exceptions(): void + { + // an invalid level is passed + $exceptionWasThrown = false; + try { + Control::prepare(fn() => 'a')->level('BLAH'); + } catch (ClarityControlInitialisationException) { + $exceptionWasThrown = true; + } + self::assertTrue($exceptionWasThrown); + } + + + + /** + * Test that a processed exception's context is forgotten after being processed. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_that_exception_contexts_are_forgotten_after_being_processed1(): void + { + $origException = null; + $origContext = null; + $callback = function (Throwable $exception, Context $context) use (&$origException, &$origContext) { + $origException = $exception; + $origContext = $context; + throw new Exception('Exception during callback'); // <<< exception occurs during processing + }; + + try { + Control::prepare(self::throwExceptionClosure()) + ->callback($callback) + ->rethrow() + ->execute(); + } catch (Throwable $callbackException) { + + self::assertNotSame($callbackException, $origException); + + $origException + ? self::assertNotSame($origContext, Clarity::getExceptionContext($origException)) + : throw new Exception('$origException was not populated'); + } + } + + + /** + * Test that ->getException($e) still retrieves the exception, even when the exception has been suppressed. + * + * @test + * + * @return void + */ + public static function test_that_get_exception_retrieves_it_even_when_suppressed(): void + { + Control::prepare(self::throwExceptionClosure()) + ->getException($e) + ->suppress() + ->execute(); + + self::assertInstanceOf(Throwable::class, $e); + } + + + /** + * Test that Control doesn't interfere with Laravel's normal error reporting functionality. + * + * @test + * + * @return void + */ + public static function test_that_normal_report_functionality_isnt_interfered_with(): void + { + if (!Environment::isLaravel()) { + self::markTestSkipped("This test only runs when using Laravel"); + } + + self::logShouldReceive(Settings::REPORTING_LEVEL_ERROR); + report(new Exception('test')); + } + + + + /** + * Test that things run properly when Clarity Context is disabled. + * + * @test + * @dataProvider disableClarityContextDataProvider + * + * @param class-string|null $exceptionToTrigger The exception type to trigger (if any). + * @param boolean $useCallback Pass a callback to Clarity. + * @param boolean $report Report the exception. + * @param boolean $rethrow Rethrow the exception. + * @param boolean $expectCallbackToBeRun Except the exception callback to be run?. + * @param boolean $expectExceptionToBeLogged Expect the exception to be logged?. + * @param boolean $expectExceptionThrownToCaller Except the exception to be thrown to the caller?. + * @return void + */ + public static function test_that_things_run_when_clarity_context_is_disabled( + ?string $exceptionToTrigger, + bool $useCallback, + bool $report, + bool $rethrow, + bool $expectCallbackToBeRun, + bool $expectExceptionToBeLogged, + bool $expectExceptionThrownToCaller, + ): void { + + Framework::config()->updateConfig([InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.enabled' => false]); + + // set up the closure to run + $intendedReturnValue = mt_rand(); + $closureRunCount = 0; + $closure = function () use (&$closureRunCount, $intendedReturnValue, $exceptionToTrigger) { + $closureRunCount++; + if (!is_null($exceptionToTrigger)) { + /** @var Throwable $exception */ + $exception = new $exceptionToTrigger(self::$exceptionMessage); + throw $exception; + } + return $intendedReturnValue; + }; + + + + $exceptionCallbackWasRun = false; + $callback = function (Context $context, Throwable $e) use ($report, $rethrow, &$exceptionCallbackWasRun) { + + $callStack = $context->getCallStack(); + $trace = $context->getStackTrace(); + + // no meta-objects will be collected when Clarity is disabled + self::assertSame($e, $context->getException()); + self::assertSame(0, count($callStack->getMeta())); // doesn't track meta-data + self::assertSame([], $context->getKnown()); // doesn't track "known" + self::assertSame(false, $context->hasKnown()); // doesn't track "known" + self::assertSame(['some-channel1', 'some-channel2'], $context->getChannels()); + self::assertSame(Settings::REPORTING_LEVEL_DEBUG, $context->getLevel()); + self::assertSame($report, $context->getReport()); + self::assertSame($rethrow ? $e : false, $context->getRethrow()); + + self::assertTrue(count($callStack) > 0); // has frames + self::assertNull($callStack->getLastApplicationFrameIndex()); + self::assertNull($callStack->getLastApplicationFrame()); + self::assertNull($callStack->getExceptionThrownFrameIndex()); + self::assertNull($callStack->getExceptionThrownFrame()); + self::assertNull($callStack->getExceptionCaughtFrameIndex()); + self::assertNull($callStack->getExceptionCaughtFrame()); + + self::assertTrue(count($trace) > 0); // has frames + self::assertNull($trace->getLastApplicationFrameIndex()); + self::assertNull($trace->getLastApplicationFrame()); + self::assertNull($trace->getExceptionThrownFrameIndex()); + self::assertNull($trace->getExceptionThrownFrame()); + self::assertNull($trace->getExceptionCaughtFrameIndex()); + self::assertNull($trace->getExceptionCaughtFrame()); + + $exceptionCallbackWasRun = true; + }; + + + + $default = mt_rand(); + Clarity::context(['something']); + $clarity = Control::prepare($closure) + ->default($default) + ->debug() + ->channel('some-channel1') + ->channels(['some-channel2']) + ->known('known-1234') + ->report($report) + ->rethrow($rethrow) + ->getException($exception); + if ($useCallback) { + $clarity->callback($callback); + } + + + + // Note: the actual level used is handled by the app/Exceptions/Handler.php + // in Laravel, it's logged as error unless updated + $expectExceptionToBeLogged + ? self::logShouldReceive(Settings::REPORTING_LEVEL_ERROR) + : self::logShouldNotReceive(Settings::REPORTING_LEVEL_ERROR); + + + + // run the closure + $exceptionWasDetectedOutside = false; + $returnValue = null; + try { + $returnValue = $clarity->execute(); + } catch (Throwable $e) { +// dump("Exception: \"{$e->getMessage()}\" in {$e->getFile()}:{$e->getLine()}"); + $exceptionWasDetectedOutside = true; + } + + + + self::assertSame(1, $closureRunCount); + self::assertSame($expectCallbackToBeRun, $exceptionCallbackWasRun); + self::assertSame($expectExceptionThrownToCaller, $exceptionWasDetectedOutside); + + if (is_null($exceptionToTrigger)) { + self::assertSame($intendedReturnValue, $returnValue); + } else { + $expectExceptionThrownToCaller + ? self::assertNull($returnValue) + : self::assertSame($default, $returnValue); + } + + if ($exceptionToTrigger) { + self::assertInstanceOf($exceptionToTrigger, $exception); + } else { + self::assertNull($exception); + } + } + + /** + * DataProvider for test_that_things_run_when_clarity_context_is_disabled(). + * + * @return array> + */ + public static function disableClarityContextDataProvider(): array + { + $triggerExceptionTypes = [ + null, // don't throw an exception + Exception::class, + InvalidArgumentException::class, + ]; + + $return = []; + + foreach ($triggerExceptionTypes as $exceptionToTrigger) { + foreach ([true, false] as $useCallback) { + foreach ([true, false] as $report) { + foreach ([true, false] as $rethrow) { + + $expectCallbackToBeRun = $exceptionToTrigger && $useCallback && ($report || $rethrow); + $expectExceptionToBeLogged = $exceptionToTrigger && $report; + $expectExceptionThrownToCaller = $exceptionToTrigger && $rethrow; + + $return[] = [ + 'exceptionToTrigger' => $exceptionToTrigger, + 'useCallback' => $useCallback, + 'report' => $report, + 'rethrow' => $rethrow, + 'expectCallbackToBeRun' => $expectCallbackToBeRun, + 'expectExceptionToBeLogged' => $expectExceptionToBeLogged, + 'expectExceptionThrownToCaller' => $expectExceptionThrownToCaller, + ]; + } + } + } + } + + return $return; + } + + + + + + /** + * Test that the CallMeta object is inserted into the correct frame when running Control::run(..). + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_that_call_meta_is_inserted_into_the_correct_frame_when_running_run(): void + { + $callback = function (Context $context) { + $meta = $context->getCallStack()->getMeta(CallMeta::class)[0]; + self::assertSame(__FILE__, $meta->getFile()); + self::assertSame(__LINE__ + 4, $meta->getLine()); + }; + Control::globalCallback($callback); + + Control::run(fn() => throw new Exception(self::$exceptionMessage)); + } + + /** + * Test that the CallMeta object is inserted into the correct frame when running Control::prepare(..)->execute(). + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_that_call_meta_is_inserted_into_the_correct_frame_when_running_prepare_execute(): void + { + $callback = function (Context $context) { + $meta = $context->getCallStack()->getMeta(CallMeta::class)[0]; + self::assertSame(__FILE__, $meta->getFile()); + self::assertSame(__LINE__ + 4, $meta->getLine()); + }; + Control::globalCallback($callback); + + Control::prepare(fn() => throw new Exception(self::$exceptionMessage))->execute(); + } + + + + /** + * Test that Control calls can be nested. + * + * Also tests that Clarity Context can hold Context objects for more than one exception at a time. + * + * @test + * + * @return void + */ + public static function test_that_clarity_calls_can_be_nested() + { + $callback = function (Throwable $exception, Context $context1) { + $exception1 = $exception; + + $callback2 = function (Throwable $exception, Context $context2) use ($exception1, $context1) { + $exception2 = $exception; + + // if Clarity Context doesn't hold one of these context objects, it will build a new one based on the + // exception + // when that happens, the resulting context object won't be the same as the one it previously reported + self::assertSame($context1, Clarity::getExceptionContext($exception1)); + self::assertSame($context2, Clarity::getExceptionContext($exception2)); + }; + + Control::prepare(self::throwExceptionClosure()) + ->callback($callback2) + ->execute(); + }; + + Control::prepare(self::throwExceptionClosure()) + ->callback($callback) + ->execute(); + } + + + + + + /** + * Build a closure that throws a new exception. + * + * @return callable + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + private static function throwExceptionClosure(): callable + { + return fn() => throw new Exception(self::$exceptionMessage); + } + + /** + * Assert that the logger should be called once. + * + * @param string $level The log reporting level to check. + * @return void + * @throws Exception When the framework isn't recognised. + */ + private static function logShouldReceive(string $level): void + { + if (!Environment::isLaravel()) { + throw new Exception('Log checking needs to be updated for the current framework'); + } + + Log::shouldReceive($level)->once(); + } + + /** + * Assert that the logger should not be called at all. + * + * @param string $level The log reporting level to check. + * @return void + * @throws Exception When the framework isn't recognised. + */ + private static function logShouldNotReceive(string $level): void + { + if (!Environment::isLaravel()) { + throw new Exception('Log checking needs to be updated for the current framework'); + } + + Log::shouldReceive($level)->atMost()->times(0); + } + + + + + + /** + * Test that the Control class resolves the exception to rethrow properly. + * + * @test + * @dataProvider resolveExceptionToRethrowDataProvider + * + * @param Throwable $origException The exception that "occurred". + * @param boolean|callable|null $catchTypeRethrow The rethrow value to pass when setting up Control Obj. + * @param boolean|callable|Throwable|null $contextRethrow The rethrow value to pass to Context obj in callback. + * @param Throwable|null $expected The expected exception to throw. + * @param boolean $expectException Whether an exception should be thrown. + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_resolution_of_the_exception_to_throw( + Throwable $origException, + bool|callable|null $catchTypeRethrow, + bool|callable|Throwable|null $contextRethrow, + ?Throwable $expected, + bool $expectException, + ): void { + + $rethrownException = null; + try { + + $callback = function (Context $context) use ($contextRethrow) { + if (is_null($contextRethrow)) { + return; + } + $context->setRethrow($contextRethrow); + }; + + $control = Control::prepare(fn() => throw $origException)->callback($callback); + if (!is_null($catchTypeRethrow)) { + $control->rethrow($catchTypeRethrow); + } + $control->execute(); + + } catch (Throwable $e) { + $rethrownException = $e; + } + + $expectException + ? self::assertInstanceOf(ClarityControlRuntimeException::class, $rethrownException) + : self::assertSame($expected, $rethrownException); + } + + /** + * DataProvider for test_resolution_of_which_exception_to_throw(). + * + * @return array> + */ + public static function resolveExceptionToRethrowDataProvider(): array + { + $exception1 = new Exception(); + $exception2 = new Exception(); + + $return = []; + + + + // null + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => null, + 'expected' => null, + 'expectException' => false, + ]; + + + + + + // CatchType based rethrow values - where the Control object (via its default CatchType) + // is updated with the rethrow value + + // true + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => true, + 'contextRethrow' => null, + 'expected' => $exception1, + 'expectException' => false, + ]; + + // false + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => false, + 'contextRethrow' => null, + 'expected' => null, + 'expectException' => false, + ]; + + + + // callable - returns null + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => fn() => null, + 'contextRethrow' => null, + 'expected' => null, + 'expectException' => false, + ]; + + // exception - returns false + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => fn() => false, + 'contextRethrow' => null, + 'expected' => null, + 'expectException' => false, + ]; + + // exception - returns true + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => fn() => true, + 'contextRethrow' => null, + 'expected' => $exception1, + 'expectException' => false, + ]; + + // exception - returns the same exception + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => fn() => $exception1, + 'contextRethrow' => null, + 'expected' => $exception1, + 'expectException' => false, + ]; + + // exception - returns a different exception + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => fn() => $exception2, + 'contextRethrow' => null, + 'expected' => $exception2, + 'expectException' => false, + ]; + + // exception - returns an invalid value + // null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => fn() => 'invalid', + 'contextRethrow' => null, + 'expected' => null, + 'expectException' => true, + ]; + + + + + + // Context based rethrow values - where the Context object is updated with the rethrow value (via a callback) + + // null + // false + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => false, + 'expected' => null, + 'expectException' => false, + ]; + + // null + // true + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => true, + 'expected' => $exception1, + 'expectException' => false, + ]; + + + + // null + // callable - returns null + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => fn() => null, + 'expected' => null, + 'expectException' => false, + ]; + + // null + // callable - returns false + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => fn() => false, + 'expected' => null, + 'expectException' => false, + ]; + + // null + // callable - returns true + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => fn() => true, + 'expected' => $exception1, + 'expectException' => false, + ]; + + // null + // callable - returns the same exception + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => fn() => $exception1, + 'expected' => $exception1, + 'expectException' => false, + ]; + + // null + // callable - returns a different exception + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => fn() => $exception2, + 'expected' => $exception2, + 'expectException' => false, + ]; + + // null + // callable - returns an invalid value + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => fn() => 'invalid', + 'expected' => null, + 'expectException' => true, + ]; + + + + // null + // exception - same one + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => $exception1, + 'expected' => $exception1, + 'expectException' => false, + ]; + + // null + // exception - different one + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => null, + 'contextRethrow' => $exception2, + 'expected' => $exception2, + 'expectException' => false, + ]; + + + + + + // where the Control (via its default CatchType) and Context objects are BOTH updated with rethrow values + // checks that the Context rethrow value overrides the CatchType one + + // true + // false + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => true, + 'contextRethrow' => false, + 'expected' => null, + 'expectException' => false, + ]; + + // false + // true + $return[] = [ + 'origException' => $exception1, + 'catchTypeRethrow' => false, + 'contextRethrow' => true, + 'expected' => $exception1, + 'expectException' => false, + ]; + + + return $return; + } +} diff --git a/tests/Unit/Exceptions/ExceptionUnitTest.php b/tests/Unit/Exceptions/ExceptionUnitTest.php new file mode 100644 index 0000000..50c2fe0 --- /dev/null +++ b/tests/Unit/Exceptions/ExceptionUnitTest.php @@ -0,0 +1,47 @@ +getMessage() + ); + + self::assertSame( + 'Please call Control::prepare(…) first before calling someMethod()', + ClarityControlInitialisationException::runPrepareFirst('someMethod')->getMessage() + ); + + + + // ClarityControlRuntimeException + + self::assertSame( + 'Invalid rethrow value given. It must be a boolean, null, or a Throwable', + ClarityControlRuntimeException::invalidRethrowValue()->getMessage() + ); + } +} diff --git a/tests/Unit/Support/InspectorConfigUnitTest.php b/tests/Unit/Support/InspectorConfigUnitTest.php new file mode 100644 index 0000000..10796f0 --- /dev/null +++ b/tests/Unit/Support/InspectorConfigUnitTest.php @@ -0,0 +1,359 @@ + $config Config values to set. + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @param MethodCalls $fallbackCalls Methods to call when initialising the fallback CatchType + * object. + * @param string[] $expectedGetChannels The expected channels. + * @param string|null $expectedGetLevel The expected level. + * @param boolean|null $expectedShouldReport The expected should-report. + * @return void + * @throws Exception When a method doesn't exist when instantiating the CatchType class. + */ + public static function test_that_config_values_are_used_by_inspector( + array $config, + MethodCalls $initMethodCalls, + MethodCalls $fallbackCalls, + array $expectedGetChannels, + ?string $expectedGetLevel, + ?bool $expectedShouldReport, + ): void { + + Framework::config()->updateConfig($config); + + $fallbackCallback = fn() => 'hello'; + $callback = fn() => 'hello'; + + $fallbackCatchType = self::buildCatchType($fallbackCalls, $fallbackCallback); + $catchType = self::buildCatchType($initMethodCalls, $callback); + + $inspector = new Inspector($catchType, $fallbackCatchType); + + self::assertSame($expectedGetChannels, $inspector->resolveChannels()); + self::assertSame($expectedGetLevel, $inspector->resolveLevel()); + self::assertSame($expectedShouldReport, $inspector->shouldReport()); + } + + /** + * Build a CatchType from InitMethodCalls. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @param callable $callback The exception callback to use. + * @return CatchType + * @throws Exception When a method doesn't exist when instantiating the CatchType class. + */ + private static function buildCatchType(MethodCalls $initMethodCalls, callable $callback): CatchType + { + $catchTypeObject = new CatchType(); + foreach ($initMethodCalls->getCalls() as $methodCall) { + + $method = $methodCall->getMethod(); + $args = $methodCall->getArgs(); + + // place the exception callback into the args for calls to callback() + if (($method == 'callback') && ($args[0] ?? null)) { + $args[0] = $callback; + } + + $toCall = [$catchTypeObject ?? CatchType::class, $method]; + if (is_callable($toCall)) { + $catchTypeObject = call_user_func_array($toCall, $args); + } else { + throw new Exception("Can't call method $method on class CatchType"); + } + } + /** @var CatchType $catchTypeObject */ + return $catchTypeObject; + } + + + + /** + * DataProvider for test_that_config_values_are_used(). + * + * Provide the different combinations of config values and CatchTypes. + * + * @return array> + */ + public static function configDataProvider(): array + { + $return = []; + + + + $channelsWhenKnownCombinations = [ + 'known-channel', + ['known-channel1', 'known-channel2'], + null, + ]; + + $channelsWhenNotKnownCombinations = [ + 'default-channel', + ['default-channel1', 'default-channel2'], + null, + ]; + + $catchTypeMethodCombinations = [ + MethodCalls::add('channel', ['catch-type-channel']), + MethodCalls::add('channel', ['catch-type-channel'])->add('known', ['a']), + MethodCalls::new(), + MethodCalls::new()->add('known', ['a']), + ]; + + $fallbackCatchTypeCombinations = [ + MethodCalls::add('channel', ['fallback-catch-type-channel']), + MethodCalls::add('channel', ['fallback-catch-type-channel'])->add('known', ['a']), + MethodCalls::new(), + MethodCalls::new()->add('known', ['a']), + ]; + + foreach ($channelsWhenKnownCombinations as $whenKnown) { + foreach ($channelsWhenNotKnownCombinations as $whenNotKnown) { + foreach ($catchTypeMethodCombinations as $initMethodCalls) { + foreach ($fallbackCatchTypeCombinations as $fallbackCalls) { + + $config = [ + 'logging.default' => 'default-channel', + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => $whenKnown, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' + => $whenNotKnown, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => true, + ]; + + $return[] = self::buildParams($config, $initMethodCalls, $fallbackCalls); + } + } + } + } + + + + $levelWhenKnownCombinations = [ + Settings::REPORTING_LEVEL_DEBUG, + null, + ]; + + $levelWhenNotKnownCombinations = [ + Settings::REPORTING_LEVEL_EMERGENCY, + null, + ]; + + $catchTypeMethodCombinations = [ + MethodCalls::add('level', [Settings::REPORTING_LEVEL_DEBUG]), + MethodCalls::add('level', [Settings::REPORTING_LEVEL_DEBUG])->add('known', ['a']), + MethodCalls::new(), + MethodCalls::new()->add('known', ['a']), + ]; + + $fallbackCatchTypeCombinations = [ + MethodCalls::add('level', [Settings::REPORTING_LEVEL_EMERGENCY]), + MethodCalls::add('level', [Settings::REPORTING_LEVEL_EMERGENCY])->add('known', ['a']), + MethodCalls::new(), + MethodCalls::new()->add('known', ['a']), + ]; + + foreach ($levelWhenKnownCombinations as $whenKnown) { + foreach ($levelWhenNotKnownCombinations as $whenNotKnown) { + foreach ($catchTypeMethodCombinations as $initMethodCalls) { + foreach ($fallbackCatchTypeCombinations as $fallbackCalls) { + + $config = [ + 'logging.default' => 'default-channel', + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_known' => $whenKnown, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known' => $whenNotKnown, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => true, + ]; + + $return[] = self::buildParams($config, $initMethodCalls, $fallbackCalls); + } + } + } + } + + + + $reportCombinations = [ + true, + false, + null, + ]; + + $catchTypeMethodCombinations = [ + MethodCalls::add('report', [true]), + MethodCalls::add('report', [false]), + MethodCalls::new(), + ]; + + $fallbackCatchTypeCombinations = [ + MethodCalls::add('report', [true]), + MethodCalls::add('report', [false]), + MethodCalls::new(), + ]; + + foreach ($reportCombinations as $report) { + foreach ($catchTypeMethodCombinations as $initMethodCalls) { + foreach ($fallbackCatchTypeCombinations as $fallbackCalls) { + + $config = [ + 'logging.default' => 'default-channel', + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known' => null, + InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => $report, + ]; + + $return[] = self::buildParams($config, $initMethodCalls, $fallbackCalls); + } + } + } + + return $return; + } + + + + /** + * Determine the parameters to pass to the test_that_config_values_are_used test. + * + * @param array $config Config values to set. + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @param MethodCalls $fallbackCalls Methods to call when initialising the fallback CatchType object. + * @return array + */ + private static function buildParams( + array $config, + MethodCalls $initMethodCalls, + MethodCalls $fallbackCalls, + ): array { + + $catchTypeKnown = $initMethodCalls->getAllCallArgsFlat('known'); + $fallbackKnown = $fallbackCalls->getAllCallArgsFlat('known'); + $isKnown = ((count($catchTypeKnown)) || (count($fallbackKnown))); + + $catchTypeChannels = $initMethodCalls->getAllCallArgsFlat('channel'); + $fallbackChannels = $fallbackCalls->getAllCallArgsFlat('channel'); + + $catchTypeLevel = last($initMethodCalls->getAllCallArgsFlat('level')); + $catchTypeLevel = ($catchTypeLevel !== false) + ? $catchTypeLevel + : null; + $fallbackLevel = last($fallbackCalls->getAllCallArgsFlat('level')); + $fallbackLevel = ($fallbackLevel !== false) + ? $fallbackLevel + : null; + + $catchTypeReport = null; + foreach ($initMethodCalls->getCalls(['report', 'dontReport']) as $methodCall) { + /** @var 'report'|'dontReport' $method */ + $method = $methodCall->getMethod(); + $catchTypeReport = match ($method) { + 'report' => (bool) ($methodCall->getArgs()[0] ?? true), + 'dontReport' => false, + }; + } + + $fallbackReport = null; + foreach ($fallbackCalls->getCalls(['report', 'dontReport']) as $methodCall) { + /** @var 'report'|'dontReport' $method */ + $method = $methodCall->getMethod(); + $fallbackReport = match ($method) { + 'report' => (bool) ($methodCall->getArgs()[0] ?? true), + 'dontReport' => false, + }; + } + + + $knownKey = $isKnown ? 'when_known' : 'when_not_known'; + $expectedGetChannels = $catchTypeChannels + ?: $fallbackChannels + ?: $config[InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.' . $knownKey] + ?? [$config['logging.default']]; + $expectedGetChannels = is_array($expectedGetChannels) + ? $expectedGetChannels + : [$expectedGetChannels]; + + + + $expectedGetLevel = $catchTypeLevel + ?? $fallbackLevel + ?? $config[InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.' . $knownKey] + ?? Settings::REPORTING_LEVEL_ERROR; // default + + + + $expectedShouldReport = $catchTypeReport + ?? $fallbackReport + ?? $config[InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report'] + ?? true; // default true + + + + return [ + 'config' => $config, + 'initMethodCalls' => $initMethodCalls, + 'fallbackInitMethodCalls' => $fallbackCalls, + 'expectedGetChannels' => $expectedGetChannels, + 'expectedGetLevel' => $expectedGetLevel, + 'expectedShouldReport' => $expectedShouldReport, + ]; + } + + + + + + /** + * Test that an invalid "level" value from the config, will trigger an exception when accessed by Inspector. + * + * @test + * + * @return void + */ + public static function test_that_invalid_config_level_triggers_an_exception_within_inspector(): void + { + Framework::config()->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known' => 'INVALID'] + ); + + $inspector = new Inspector(new CatchType()); + + $exceptionWasThrown = false; + try { + $inspector->resolveLevel(); + } catch (ClarityContextInitialisationException) { + $exceptionWasThrown = true; + } + + self::assertTrue($exceptionWasThrown); + } +} diff --git a/tests/Unit/Support/InspectorUnitTest.php b/tests/Unit/Support/InspectorUnitTest.php new file mode 100644 index 0000000..e7415b6 --- /dev/null +++ b/tests/Unit/Support/InspectorUnitTest.php @@ -0,0 +1,645 @@ + 'hello'; + $exceptionCallback = fn() => 'hello'; + + $fallbackCatchType = self::buildCatchType($fallbackCalls, $fallbackExceptionCallback); + $catchType = self::buildCatchType($initMethodCalls, $exceptionCallback); + + /** @var Throwable|null $exception */ + $exception = $exceptionTypeToTrigger + ? new $exceptionTypeToTrigger(self::$exceptionMessage) + : null; + $inspector = new Inspector($catchType, $fallbackCatchType); + + // callback - the actual callable picked compared below + $expectedCallback = $expectedUseCallback + ? [$exceptionCallback] + : ($expectedUseFallbackCallback + ? [$fallbackExceptionCallback] + : [] + ); + + // channels - pick the config's values when not specified + $channels = Framework::config()->getChannelsWhenKnown(); + if ((!$expectedGetChannels) && ($expectedGetKnown) && (count($channels))) { + $expectedGetChannels = $channels; + } + $channels = Framework::config()->getChannelsWhenNotKnown(); + if ((!$expectedGetChannels) && (count($channels))) { + $expectedGetChannels = $channels; + } + if (!$expectedGetChannels) { + $expectedGetChannels = []; + } + + // level - pick the config's values when not specified + if ($expectedGetKnown) { + $expectedGetLevel = $expectedGetLevel + ?? Framework::config()->getLevelWhenKnown() + ?? Framework::config()->getLevelWhenNotKnown(); + } else { + $expectedGetLevel = $expectedGetLevel + ?? Framework::config()->getLevelWhenNotKnown(); + } + + // report + $expectedShouldReport ??= Framework::config()->getReport(); + + $matched = $exception + ? $inspector->checkForMatch($exception) + : null; + self::assertSame($expectedCheckForMatch, $matched); + self::assertSame($expectedGetExceptionClasses, $inspector->getExceptionClasses()); + self::assertSame($expectedCallback, $inspector->resolveCallbacks()); + self::assertSame($expectedGetKnown, $inspector->resolveKnown()); + self::assertSame(count($expectedGetKnown) > 0, $inspector->hasKnown()); + self::assertSame($expectedGetChannels, $inspector->resolveChannels()); + self::assertSame($expectedGetLevel, $inspector->resolveLevel()); + self::assertSame($expectedShouldReport, $inspector->shouldReport()); + self::assertSame($expectedRethrow, $inspector->pickRethrow()); + self::assertSame($expectedDefault, $inspector->resolveDefault()); + } + + /** + * Build a CatchType from InitMethodCalls. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @param callable $callback The exception callback to use. + * @return CatchType + * @throws Exception When a method doesn't exist when instantiating the Catch Type class. + */ + private static function buildCatchType(MethodCalls $initMethodCalls, callable $callback): CatchType + { + $catchTypeObject = null; + foreach ($initMethodCalls->getCalls() as $methodCall) { + + $method = $methodCall->getMethod(); + $args = $methodCall->getArgs(); + + // place the exception callback into the args for calls to callback() + if (($method == 'callback') && ($args[0] ?? null)) { + $args[0] = $callback; + } + + $toCall = [$catchTypeObject ?? CatchType::class, $method]; + if (is_callable($toCall)) { + $catchTypeObject = call_user_func_array($toCall, $args); + } else { + throw new Exception("Can't call method $method on class CatchType"); + } + } + /** @var CatchType|null $catchTypeObject */ + return $catchTypeObject ?? new CatchType(); + } + + + + /** + * DataProvider for test_that_inspector_operates_properly(). + * + * Provide the different combinations of how the CatchType object can be set up and called. + * + * @return array> + */ + public static function inspectorDataProvider(): array + { + $typeCombinations = [ + null, // don't call + [Throwable::class], + [InvalidArgumentException::class], + [DivisionByZeroError::class], + ]; + + $matchCombinations = [ + null, // don't call + [self::$exceptionMessage], + ['(NO MATCH)'], + ]; + + $matchRegexCombinations = [ + null, // don't call + ['/Something/'], + ['(NO MATCH)'], + ]; + + $callbackCombinations = [ + null, // don't call + [true], // is replaced with the callback later, inside the test + ]; + + $knownCombinations = [ + null, // don't call + ['ABC-123'], +// [['ABC-123', 'DEF-456']], + ]; + + $channelCombinations = [ + null, // don't call + ['stack'], + ['stack', 'slack'], + ]; + + $levelCombinations = [ + null, // don't call + [Settings::REPORTING_LEVEL_INFO], +// ['BLAH'], // error + ]; + + $reportCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $rethrowCombinations = [ + null, // don't call + [], // called with no arguments + ]; + + $triggerExceptionTypes = [ + null, // don't throw an exception + Exception::class, + InvalidArgumentException::class, + ]; + + $defaultCombinations = [ + null, // don't call + [true], + ['something'], + ]; + + + + $return = []; + + foreach ($triggerExceptionTypes as $exceptionTypeToTrigger) { + + $allInitMethodCallGroups = []; + $allInitMethodCallGroup = []; + foreach ($typeCombinations as $type) { + + foreach ($matchCombinations as $match) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('match', $match); + } + + foreach ($matchRegexCombinations as $regex) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('matchRegex', $regex); + } + + $allInitMethodCallGroups[] = $allInitMethodCallGroup; + } + + $allInitMethodCallGroup = []; + foreach ($typeCombinations as $type) { + + foreach ($callbackCombinations as $callback) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('callback', $callback); + } + + $allInitMethodCallGroups[] = $allInitMethodCallGroup; + } + + $allInitMethodCallGroup = []; + foreach ($typeCombinations as $type) { + + foreach ($knownCombinations as $known) { + foreach ($channelCombinations as $channels) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('known', $known) + ->add('channels', $channels); + } + } + + $allInitMethodCallGroups[] = $allInitMethodCallGroup; + } + + $allInitMethodCallGroup = []; + foreach ($typeCombinations as $type) { + + foreach ($knownCombinations as $known) { + foreach ($levelCombinations as $level) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('known', $known) + ->add('level', $level); + } + } + + $allInitMethodCallGroups[] = $allInitMethodCallGroup; + } + + $allInitMethodCallGroup = []; + foreach ($typeCombinations as $type) { + + foreach ($reportCombinations as $report) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('report', $report); + } + + foreach ($rethrowCombinations as $rethrow) { + $allInitMethodCallGroup[] = MethodCalls::new() + ->add('catch', $type) + ->add('rethrow', $rethrow); + } + + $allInitMethodCallGroups[] = $allInitMethodCallGroup; + } + + + // create the combinations of these calls + foreach ($allInitMethodCallGroups as $allInitMethodCallGroup) { + foreach ($allInitMethodCallGroup as $initMethodCalls1) { + foreach ($allInitMethodCallGroup as $initMethodCalls2) { + + if (!$initMethodCalls1->hasCalls()) { + continue; + } + if (!$initMethodCalls2->hasCalls()) { + continue; + } + + $return[] = self::buildParams( + $initMethodCalls2, + $initMethodCalls1, + $exceptionTypeToTrigger + ); + } + } + } + } + + + + + + // different rethrow values + $rethrowCombinations = [ + null, // don't call + [], // called with no arguments + [true], + [false], + [fn() => true], + ]; + + $allInitMethodCallGroups = []; + foreach ($rethrowCombinations as $rethrow) { + $allInitMethodCallGroups[] = MethodCalls::new()->add('rethrow', $rethrow); + } + + foreach ($allInitMethodCallGroups as $initMethodCalls1) { + foreach ($allInitMethodCallGroups as $initMethodCalls2) { + + if (!$initMethodCalls1->hasCalls()) { + continue; + } + if (!$initMethodCalls2->hasCalls()) { + continue; + } + + $return[] = self::buildParams( + $initMethodCalls2, + $initMethodCalls1, + $exceptionTypeToTrigger + ); + } + } + + + + + + // method calls that aren't multiplied out by the exception types and catch combinations + $exceptionTypeToTrigger = null; + + $allInitMethodCallGroups = []; + foreach ($defaultCombinations as $default) { + $allInitMethodCallGroups[] = MethodCalls::new()->add('default', $default); + } + + foreach ($allInitMethodCallGroups as $initMethodCalls1) { + foreach ($allInitMethodCallGroups as $initMethodCalls2) { + + if (!$initMethodCalls1->hasCalls()) { + continue; + } + if (!$initMethodCalls2->hasCalls()) { + continue; + } + + $return[] = self::buildParams( + $initMethodCalls2, + $initMethodCalls1, + $exceptionTypeToTrigger + ); + } + } + + return $return; + } + + + + /** + * Determine the parameters to pass to the test_that_inspector_operates_properly test. + * + * @param MethodCalls $initMethodCalls Methods to call when initialising the CatchType object. + * @param MethodCalls $fallbackCalls Methods to call when initialising the fallback CatchType object. + * @param string|null $exceptionToTrigger The exception type to trigger (if any). + * @return array + */ + private static function buildParams( + MethodCalls $initMethodCalls, + MethodCalls $fallbackCalls, + ?string $exceptionToTrigger = null + ): array { + + $willBeCaughtBy = self::determineWhatWillCatchTheException( + $exceptionToTrigger, + $fallbackCalls, + $initMethodCalls + ); + + $catchTypeHasCallback = count($initMethodCalls->getAllCallArgsFlat('callback')) > 0; + $fallbackHasCallback = count($fallbackCalls->getAllCallArgsFlat('callback')) > 0; + + $catchTypeKnown = $initMethodCalls->getAllCallArgsFlat('known'); + $fallbackKnown = $fallbackCalls->getAllCallArgsFlat('known'); + + $catchTypeChannels = $initMethodCalls->getAllCallArgsFlat('channels'); + $catchTypeChannels = $catchTypeChannels ?: $initMethodCalls->getAllCallArgsFlat('channel'); + $fallbackChannels = $fallbackCalls->getAllCallArgsFlat('channels'); + $fallbackChannels = $fallbackChannels ?: $fallbackCalls->getAllCallArgsFlat('channel'); + + $catchTypeLevel = last($initMethodCalls->getAllCallArgsFlat('level')); + $catchTypeLevel = ($catchTypeLevel !== false) + ? $catchTypeLevel + : null; + $fallbackLevel = last($fallbackCalls->getAllCallArgsFlat('level')); + $fallbackLevel = ($fallbackLevel !== false) + ? $fallbackLevel + : null; + + $catchTypeReport = null; + foreach ($initMethodCalls->getCalls(['report', 'dontReport']) as $methodCall) { + /** @var 'report'|'dontReport' $method */ + $method = $methodCall->getMethod(); + $catchTypeReport = match ($method) { + 'report' => (bool) ($methodCall->getArgs()[0] ?? true), + 'dontReport' => false, + }; + } + + $fallbackReport = null; + foreach ($fallbackCalls->getCalls(['report', 'dontReport']) as $methodCall) { + /** @var 'report'|'dontReport' $method */ + $method = $methodCall->getMethod(); + $fallbackReport = match ($method) { + 'report' => (bool) ($methodCall->getArgs()[0] ?? true), + 'dontReport' => false, + }; + } + + $catchTypeRethrow = null; + foreach ($initMethodCalls->getCalls(['rethrow', 'dontRethrow']) as $methodCall) { + /** @var 'rethrow'|'dontRethrow' $method */ + $method = $methodCall->getMethod(); + $catchTypeRethrow = match ($method) { + 'rethrow' => $methodCall->getArgs()[0] ?? true, + 'dontRethrow' => false, + }; + } + + $fallbackRethrow = null; + foreach ($fallbackCalls->getCalls(['rethrow', 'dontRethrow']) as $methodCall) { + /** @var 'rethrow'|'dontRethrow' $method */ + $method = $methodCall->getMethod(); + $fallbackRethrow = match ($method) { + 'rethrow' => $methodCall->getArgs()[0] ?? true, + 'dontRethrow' => false, + }; + } + + $catchTypeDefault = null; + foreach ($initMethodCalls->getCalls(['default']) as $methodCall) { + $catchTypeDefault = $methodCall->getArgs()[0] ?? null; + } + + $fallbackDefault = null; + foreach ($fallbackCalls->getCalls(['default']) as $methodCall) { + $fallbackDefault = $methodCall->getArgs()[0] ?? null; + } + + return [ + 'initMethodCalls' => $initMethodCalls, + 'fallbackInitMethodCalls' => $fallbackCalls, + 'exceptionToTrigger' => $exceptionToTrigger, + 'expectedCheckForMatch' => $exceptionToTrigger + ? !is_null($willBeCaughtBy) + : null, + 'expectedGetExceptionClasses' => $initMethodCalls->getAllCallArgsFlat('catch'), + 'expectedUseCallback' => $catchTypeHasCallback, + 'expectedUseFallbackCallback' => !$catchTypeHasCallback && $fallbackHasCallback, + 'expectedGetKnown' => $catchTypeKnown ?: $fallbackKnown, + 'expectedGetChannels' => $catchTypeChannels ?: $fallbackChannels ?: ['stack'], + 'expectedGetLevel' => $catchTypeLevel ?: $fallbackLevel ?: Settings::REPORTING_LEVEL_ERROR, + 'expectedShouldReport' => $catchTypeReport ?? $fallbackReport ?? true, // default true + 'expectedShouldRethrow' => $catchTypeRethrow ?? $fallbackRethrow ?? false, // default false + 'expectedDefault' => $catchTypeDefault ?? $fallbackDefault ?? null, // default null + ]; + } + + + + /** + * Determine if a thrown exception will be caught. + * + * @param string|null $exceptionToTrigger The exception type to trigger (if any). + * @param MethodCalls $fallbackCalls Methods to call when initialising the fallback CatchType object. + * @param MethodCalls|null $initMethodCalls Methods to call when initialising the CatchType object. + * @return MethodCalls|null + */ + private static function determineWhatWillCatchTheException( + ?string $exceptionToTrigger, + MethodCalls $fallbackCalls, + ?MethodCalls $initMethodCalls, + ): ?MethodCalls { + + if (is_null($exceptionToTrigger)) { + return null; + } + + + /** @var string[] $fallbackMatchStrings */ + $fallbackMatchStrings = $fallbackCalls->getAllCallArgsFlat('match'); + + /** @var string[] $fallbackMatchRegexes */ + $fallbackMatchRegexes = $fallbackCalls->getAllCallArgsFlat('matchRegex'); + + if ($initMethodCalls) { + + // check the main CatchType settings first + /** @var string[] $catchClasses */ + $catchClasses = $initMethodCalls->getAllCallArgsFlat('catch'); + /** @var string[] $matchStrings */ + $matchStrings = $initMethodCalls->getAllCallArgsFlat('match') ?: $fallbackMatchStrings; + /** @var string[] $matchRegex */ + $matchRegex = $initMethodCalls->getAllCallArgsFlat('matchRegex') ?: $fallbackMatchRegexes; + + $a = self::checkIfMatchesMatch($matchStrings); + $b = self::checkIfMatchRegexesMatch($matchRegex); + + if ( + (self::checkIfExceptionClassesMatch($exceptionToTrigger, $catchClasses)) + && ($a || $b || (is_null($a) && is_null($b))) + ) { + return $initMethodCalls; + } + } + + + +// // if there are CatchTypes, and the fall-back doesn't define class/es to catch, then stop +// if (($initMethodCalls) && (!$fallbackCalls->hasCall('catch'))) { +// return null; +// } +// +// // check the fallback settings second +// if ( +// (self::checkIfExceptionClassesMatch($exceptionToTrigger, $fallbackCatchClasses)) +// && (self::checkIfMatchesMatch($fallbackMatchStrings)) +// ) { +// return $fallbackCalls; +// } + + return null; + } + + /** + * Check if an array of exception classes match the exception type. + * + * @param string $exceptionToTrigger The exception type that will be triggered. + * @param string[] $exceptionClasses The exception types to catch. + * @return boolean + */ + private static function checkIfExceptionClassesMatch(string $exceptionToTrigger, array $exceptionClasses): bool + { + if (!count($exceptionClasses)) { + return true; // implies that all exceptions should be caught + } + if (in_array(Throwable::class, $exceptionClasses)) { + return true; + } + if (in_array($exceptionToTrigger, $exceptionClasses)) { + return true; + } + return false; + } + + /** + * Check if an array of match strings would match the exception message. + * + * @param string[] $matchStrings The matches to try. + * @return boolean|null + */ + private static function checkIfMatchesMatch(array $matchStrings): ?bool + { + if (!count($matchStrings)) { + return null; + } + return in_array(self::$exceptionMessage, $matchStrings); + } + + /** + * Check if the regex strings match. + * + * @param string[] $regexes The regular expressions to try. + * @return boolean|null + */ + private static function checkIfMatchRegexesMatch(array $regexes): ?bool + { + if (!count($regexes)) { + return null; + } + + foreach ($regexes as $regex) { + if (preg_match($regex, self::$exceptionMessage)) { + return true; + } + } + return false; + } +} diff --git a/tests/Unit/Support/SupportUnitTest.php b/tests/Unit/Support/SupportUnitTest.php new file mode 100644 index 0000000..55fefa4 --- /dev/null +++ b/tests/Unit/Support/SupportUnitTest.php @@ -0,0 +1,112 @@ + $args The "new" arguments to add. + * @param mixed[] $expected The expected output. + * @return void + */ + public static function test_normalise_args_method(array $previous, array $args, array $expected): void + { + $normalised = Support::normaliseArgs($previous, $args); + self::assertSame($expected, $normalised); + } + + /** + * DataProvider for test_that_arguments_are_normalised(). + * + * @return array> + */ + public static function argumentDataProvider(): array + { + $value1 = 'a'; + $value2 = 'b'; + $value3 = 'c'; + $value4 = 'd'; + + $array1 = [$value1]; + $array2 = [$value2]; + $array3 = [$value3]; + $array4 = [$value4]; + + $object1 = (object) [$value1 => $value1]; + $object2 = (object) [$value2 => $value2]; + $object3 = (object) [$value3 => $value3]; + $object4 = (object) [$value4 => $value4]; + + return [ + ...self::buildSetOfArgs($value1, $value2, $value3, $value4), + ...self::buildSetOfArgs($array1, $array2, $array3, $array4), + ...self::buildSetOfArgs($object1, $object2, $object3, $object4), + ]; + } + + /** + * Build combinations of inputs to test. + * + * @param mixed $one Value 1. + * @param mixed $two Value 2. + * @param mixed $three Value 3. + * @param mixed $four Value 4. + * @return array> + */ + private static function buildSetOfArgs(mixed $one, mixed $two, mixed $three, mixed $four): array + { + return [ + self::buildArgs([], []), + self::buildArgs([$one, $two], []), + self::buildArgs([], [$one, $two]), + self::buildArgs([$one, $two], [$three, $four]), + self::buildArgs([$one, $two], [$two, $three]), + self::buildArgs([$one, $one], []), + self::buildArgs([], [$one, $one]), + self::buildArgs([null], [$one, $two]), + self::buildArgs([$one, $two], [null]), + ]; + } + + /** + * @param mixed[] $previous The "previous" arguments. + * @param array $args The "new" arguments to add. + * @return array + */ + private static function buildArgs(array $previous, array $args): array + { + foreach ($args as $arg) { + $arg = is_array($arg) + ? $arg + : [$arg]; + $previous = array_merge($previous, $arg); + } + + $expected = array_values( + array_unique( + array_filter($previous), + SORT_REGULAR + ) + ); + + return [ + 'previous' => $previous, + 'args' => $args, + 'expected' => $expected, + ]; + } +} diff --git a/tests/Unit/Traits/HasCatchTypesUnitTest.php b/tests/Unit/Traits/HasCatchTypesUnitTest.php new file mode 100644 index 0000000..4c1350f --- /dev/null +++ b/tests/Unit/Traits/HasCatchTypesUnitTest.php @@ -0,0 +1,83 @@ +newInstanceWithoutConstructor(); + + is_callable($toCall = [$control, $method]) + ? call_user_func_array($toCall, $args) + : throw new Exception("Can't call method $method on class Control"); + + } catch (ClarityControlInitialisationException) { + $exceptionWasThrown = true; + } + self::assertTrue($exceptionWasThrown); + } + + /** + * DataProvider for test_calling_context_before_other_initialisation_methods(). + * + * @return array|string>> + */ + public static function initialisationMethodsDataProvider(): array + { + return [ + ['method' => 'catch', [Exception::class]], + ['method' => 'match', ['abc']], + ['method' => 'matchRegex', ['/^abc/']], + ['method' => 'callback', [fn() => 'a']], + ['method' => 'callbacks', [fn() => 'a']], + ['method' => 'known', ['abc']], + ['method' => 'channel', ['abc']], + ['method' => 'channels', ['abc']], + ['method' => 'level', [Settings::REPORTING_LEVEL_WARNING]], + ['method' => 'debug', []], + ['method' => 'info', []], + ['method' => 'notice', []], + ['method' => 'warning', []], + ['method' => 'error', []], + ['method' => 'critical', []], + ['method' => 'alert', []], + ['method' => 'emergency', []], + ['method' => 'report', []], + ['method' => 'dontReport', []], + ['method' => 'rethrow', []], + ['method' => 'rethrow', [fn() => true]], + ['method' => 'dontRethrow', []], + ['method' => 'suppress', []], + ['method' => 'default', ['abc']], + ]; + } +}