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..491957b --- /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 Context's documentation](https://github.com/code-distortion/clarity-context) 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..7abe5d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,46 @@ +#### Summary: + + + + + +#### Versions: + +- Clarity Context version: +- Clarity Control 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..b59ca67 --- /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.0" ] + 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.0" + phpunit: "^9.3" + phpunit-config-file: "phpunit.github-actions.up-to-9.xml.dist" + - testbench: "^6.0" + 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.0" # Laravel 8 for lower versions of php + php: "8.3" + - testbench: "^6.0" # Laravel 8 for lower versions of php + php: "8.2" + - testbench: "^6.0" # 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..638674c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to `code-distortion/clarity-context` 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..a818c7d --- /dev/null +++ b/README.md @@ -0,0 +1,436 @@ +# Clarity Context - Understand Your Exceptions + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/code-distortion/clarity-context.svg?style=flat-square)](https://packagist.org/packages/code-distortion/clarity-context) +![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-context/run-tests.yml?branch=master&style=flat-square)](https://github.com/code-distortion/clarity-context/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-context) +[![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-context*** is a **Context Tracker** package for Laravel that gives you a birds-eye-view of what your code was doing when an exception occurs. + +[Add context](#add-context-to-your-code) to your code. e.g. + +``` php +// in a file in your project +Clarity::context('Performing checkout', ['user-id' => $userId, 'order-id' => $orderId]); +… +``` + +``` php +// in another file +Clarity::context('Sending payment request to gateway'); +Clarity::context(['payment-gateway' => 'examplexyz.com', 'card-id' => $cardId, 'amount' => $amount]); +… +``` + +This information is collected so when an exception occurs, it can be used to [show what your code was doing](#exception-logging) at the time. e.g. + +``` +app/Domain/Checkout/PerformCheckoutAction.php on line 20 (method "submit") +- "Performing checkout" +- user-id = 5 +- order-id = 123 + +app/Domain/Payments/MakePaymentAction.php on line 19 (method "handle") (last application frame) +- "Sending payment request to gateway" +- payment-gateway = 'examplexyz.com' +- card-id = 456 +- amount = '10.99' + +vendor/laravel/framework/src/Illuminate/Http/Client/PendingRequest.php on line 856 (closure) +- The exception was thrown +``` + + + +
+ + + +## Clarity Suite + +Clarity Context is a part of the ***Clarity Suite***, designed to let you manage exceptions more easily: +- **Clarity Context** - Understand Your Exceptions +- [Clarity Logger](https://github.com/code-distortion/clarity-logger) - Useful Exception Logs +- [Clarity Control](https://github.com/code-distortion/clarity-control) - Handle Your Exceptions + + + +
+ + + +## Table of Contents + +- [Installation](#installation) + - [Config File](#config-file) +- [Add Context to Your Code](#add-context-to-your-code) +- [Exception Logging](#exception-logging) + + + +## Installation + +Install the package via composer: + +``` bash +composer require code-distortion/clarity-context +``` + + + +### Config File + +Use the following command if you would like to publish the `config/code_distortion.clarity_context.php` config file. + +It simply gives you the option to turn this package on or off. + +``` bash +php artisan vendor:publish --provider="CodeDistortion\ClarityContext\ServiceProvider" --tag="config" +``` + + + +## Add Context to Your Code + +Clarity Context lets you add context details throughout your code. It keeps track of what's currently in the call stack, ready for when an exception occurs. e.g. + +``` php +Clarity::context("A quick description of what's currently happening"); +Clarity::context(['some-relevant-id' => 123]); +``` + +You can add *strings* to explain what's currently happening in a sentence, or *associative arrays* to show specific details about what your code is currently working with. + +Add context throughout your code in relevant places. Pick places that will give you the most insight when tracking down a problem. Add as many as you feel necessary. + +You can pass multiple values at once: + +``` php +Clarity::context("Processing csv file", ['file' => $file, 'category' => $categoryId]); +``` + +> ***Note:*** Don't add sensitive details that you don't want to be logged! + +If you use trace identifiers to identify requests, you can add these as well. A good place to add them would be in a [service provider](https://laravel.com/docs/10.x/providers) or [request middleware](https://laravel.com/docs/10.x/middleware). + +``` php +Clarity::traceIdentifier($traceId); +``` + + + +## Exception Logging + +To log your exceptions, install a package like [Clarity Logger](https://github.com/code-distortion/clarity-logger) that's aware of *Clarity Context*. Follow its installation instructions to add logging to your project. + +*Clarity Logger* will automatically include your context details alongside the details it normally logs. e.g. + +``` +EXCEPTION (UNCAUGHT): + +exception Illuminate\Http\Client\ConnectionException: "cURL error 6: Could not resolve host: api.example-gateway.com (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://api.example-gateway.com" +- location app/Http/Controllers/CheckoutController.php on line 50 (method "submit") +- vendor vendor/laravel/framework/src/Illuminate/Http/Client/PendingRequest.php on line 856 (closure) +request POST https://my-website.com/checkout +- referrer https://my-website.com/checkout +- route cart.checkout +- middleware web +- action CheckoutController@submit +- trace-id 1234567890 +user 3342 - Bob - bob@example.com (123.123.123.123) +- agent Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 +date/time Sunday 2nd April at 7:08pm (Australia/Sydney) 2023-04-02 19:08:23 AEST +10:00 + +CONTEXT: + +app/Domain/Checkout/PerformCheckoutAction.php on line 20 (method "handle") +- "Performing checkout" +- user-id = 5 +- order-id = 123 + +app/Domain/Payments/MakePaymentAction.php on line 19 (method "handle") (last application frame) +- "Sending payment request to gateway" +- payment-gateway = 'examplexyz.com' +- card-id = 456 +- amount = '10.99' + +vendor/laravel/framework/src/Illuminate/Http/Client/PendingRequest.php on line 856 (closure) +- The exception was thrown +``` + +
+⚙️ Click for more information. + + + +## Logging Exceptions (Advanced) + +Clarity Context collects and manages the context details you've added to your code. + +When an exception occurs, it builds a `CodeDistortion\ClarityContext\Context` object that can be used by the code doing the logging. This `Context` object contains the details you added (that were present in the call stack at the time). + +If you'd like to handle the logging yourself, or are building a package to do so - this involves updating Laravel's [exception handler](https://laravel.com/docs/10.x/errors#the-exception-handler) `app/Exceptions/Handler.php` to use these `Context` values. + +This section explains how to use this `Context` class. + + + +### Obtaining the Context Object + +Use `Clarity::getExceptionContext($e)` to access the `CodeDistortion\ClarityContext\Context` object built for that exception. + +Then you can choose how to log the exception based on what's inside the `Context` object. + +``` php +// app/Exceptions/Handler.php + +namespace App\Exceptions; + +use CodeDistortion\ClarityContext\Clarity; // <<< +use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Throwable; + +class Handler extends ExceptionHandler +{ + … + + /** + * Register the exception handling callbacks for the application. + */ + public function register(): void + { + $this->reportable(function (Throwable $e) { + + $context = Clarity::getExceptionContext($e); // <<< + // … perform formatting and logging here + }); + } +} +``` + + + +### The Context Object + +The `Context` object includes a variety of details about the exception, including: + +- the call stack / stack trace (based on `$e->getTrace()`, but with the file/line numbers shifted by one frame, so they make more sense), +- your context details, that were present in the call stack at the time the exception occurred, +- references to the location where the exception was thrown and caught. + +``` php +$context->getException(); // the exception that was caught +$context->getChannels(); // the intended channels to log to +$context->getLevel(); // the intended reporting level (debug, … emergency) +$context->getDefault(); // the default value that will be returned +$context->getTraceIdentifiers(); // the trace identifiers +$context->getKnown(); // "known" issues associated with the exception +$context->hasKnown(); // whether the exception has "known" issues or not +$context->getReport(); // whether to trigger Laravel's report() method or not +$context->getRethrow(); // whether to rethrow, a closure to resolve it, or an exception itself to throw +$context->detailsAreWorthListing(); // whether details (other than those you can get by looking at the exception alone) are available + +$stackTrace = $context->getStackTrace(); // the stack trace frames (most recent at the start) +$callStack = $context->getCallStack(); // the same as the stack trace, but in reverse +``` + + + +#### Stack Trace / Call Stack, and Frames + +You can retrieve details about the call stack frames using `$context->getStackTrace()` or `$context->getCallStack()`. They contain objects representing each frame. + +`getStackTrace()` contains the frames in order from most recent to oldest. `getCallStack()` is the same, except ordered from oldest to newest. + +They also contain the following methods to help you find particular frames and meta information. + +``` php +$stackTrace = $context->getStackTrace(); // or $context->getCallStack(); + +$stackTrace->getLastApplicationFrame(); // get the last application (i.e. non-vendor) frame +$stackTrace->getLastApplicationFrameIndex(); // get the index of the last application frame +$stackTrace->getExceptionThrownFrame(); // get the frame that threw the exception +$stackTrace->getExceptionThrownFrameIndex(); // get the index of the frame that threw the exception +$stackTrace->getExceptionCaughtFrame(); // get the frame that caught the exception +$stackTrace->getExceptionCaughtFrameIndex(); // get the index of the frame that caught the exception +$stackTrace->getMeta(); // get the Meta objects - these represent the context details, amongst others +$stackTrace->getGroupedMeta(); // get the Meta objects grouped together in MetaGroups - see below +``` + +They are iterable, allowing them to be looped through. + +You can retrieve the following details from the Frame objects inside: + +``` php +$stackTrace = $context->getStackTrace(); // or $context->getCallStack(); + +foreach ($stackTrace as $frame) { + $frame->getFile(); // the path to the file containing the code being run + $frame->getProjectFile(); // the same file, but relative to the project-root's dir + $frame->getLine(); // the relevant line number + $frame->getFunction(); // the function or method being run at the time + $frame->getClass(); // the class being used at the time + $frame->getObject(); // the object instance being used at the time + $frame->getType(); // the "type" ("::", "->") + $frame->getArgs(); // the arguments the function or method was called with + $frame->getMeta(); // retrieve the Meta objects, see below + $frame->isApplicationFrame(); // is this an application (i.e. non-vendor) frame? + $frame->isLastApplicationFrame(); // is this the last application frame (before the exception was thrown)? + $frame->isVendorFrame(); // is this a vendor frame? + $frame->isLastFrame(); // is this in the last frame in the (where the exception was thown)? + $frame->exceptionWasThrownHere(); // was the exception thrown by this frame? + $frame->exceptionWasCaughtHere(); // was the exception caught by this frame? +} +``` + +> ***Note:*** Some of the methods like `getFunction()`, `getClass()`, `getObject()` won't always return a value. It depends on the circumstance. See [PHP's debug_backtrace method](https://www.php.net/manual/en/function.debug-backtrace.php) for more details. + + + +#### Meta Objects + +There are 5 types of Meta objects: +- `ContextMeta` - when the application called `Clarity::context(…)` to add context details, +- `CallMeta` - when the Control package ran some code for the application (e.g. using `Control::run()`), +- `LastApplicationFrameMeta` - the location of the last application (i.e. non-vendor) frame, +- `ExceptionThrownMeta` - the location the exception was thrown, +- `ExceptionCaughtMeta` - the location the exception was caught. + +You can retrieve the following details from the Meta objects: + +``` php +// all Meta classes +$meta->getFile(); // the relevant file +$meta->getProjectFile(); // the same file, but relative to the project-root's dir +$meta->getLine(); // the relevant line number +$meta->getFunction(); // the function or method being run at the time +$meta->getClass(); // the class being used at the time +$meta->getType(); // the "type" ("::", "->") +// ContextMeta only +$meta->getContext(); // the context array or sentence +// CallMeta only +$meta->wasCaughtHere(); // whether the excepton was caught here or not +$meta->getKnown(); // the "known" issues associated to the exception +``` + +There are several ways of retrieving Meta objects: + +``` php +$context->getStackTrace()->getMeta(); // all the Meta objects, in stack trace order +$context->getCallStack()->getMeta(); // all the Meta objects, in call stack order +$frame->getMeta(); // the Meta objects present in a particular frame +$metaGroup->getMeta(); // related Meta objects, grouped togther (see below) +``` + +Each of these methods accepts a meta-class string, or several of them, which limit the result. e.g. + +``` php +$context->getStackTrace()->getMeta(ContextMeta::class); // only ContextMeta objects will be returned +``` + + + +#### MetaGroup Objects + +When reporting the exception details, it's useful to group the Meta objects together. `MetaGroup` objects provide a way of grouping the Meta objects in a logical way. + +The Meta objects within are related, i.e. in the same frame and on near-by lines. + +``` php +$context->getStackTrace()->getMetaGroups(); +$context->getCallStack()->getMetaGroups(); +``` + +Each MetaGroup contains similar details to the `Frame` object. + +``` php +$metaGroup->getFile(); // the path to the file containing the code being run +$metaGroup->getProjectFile(); // the same file, but relative to the project-root's dir +$metaGroup->getLine(); // the relevant line number +$metaGroup->getFunction(); // the function or method being run at the time +$metaGroup->getClass(); // the class being used at the time +$metaGroup->getType(); // the "type" ("::", "->") +$metaGroup->getMeta(); // the meta objects contained within +$metaGroup->isInApplicationFrame(); // is this in an application (i.e. non-vendor) frame? +$metaGroup->isInLastApplicationFrame(); // is this in the last application frame (before the exception was thrown)? +$metaGroup->isInVendorFrame(); // is this in a vendor frame? +$metaGroup->isInLastFrame(); // is this in the last frame (where the exception was thown)? +$metaGroup->exceptionThrownInThisFrame(); // is this in the frame the exception was thrown from? +$metaGroup->exceptionCaughtInThisFrame(); // is this in the frame the exception was caught in? +``` + + + +### Context Objects Without an Exception + +You can generate a Context object arbitrarily, without needing an exception. + +The Context object returned will contain the current context details, like it normally would. + +``` php +$context = Clarity::buildContextHere(); +``` + +
+ + + +
+ + + +## Testing This Package + +- Clone this package: `git clone https://github.com/code-distortion/clarity-context.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-context) 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..abae055 --- /dev/null +++ b/composer.json @@ -0,0 +1,75 @@ +{ + "name": "code-distortion/clarity-context", + "description": "A Context Tracker package for Laravel", + "keywords": [ + "laravel", + "error", + "exception", + "catch", + "log", + "report", + "context" + ], + "homepage": "https://github.com/code-distortion/clarity-context", + "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/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\\ClarityContext\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "CodeDistortion\\ClarityContext\\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\\ClarityContext\\ServiceProvider" + ] + } + }, + "suggest": { + "code-distortion/clarity-control": "Handle Your Exceptions. Part of the Clarity Suite", + "code-distortion/clarity-logger": "Useful Exception Logs. Part of the Clarity Suite" + } +} diff --git a/config/context.config.php b/config/context.config.php new file mode 100644 index 0000000..5408884 --- /dev/null +++ b/config/context.config.php @@ -0,0 +1,20 @@ + env('CLARITY_CONTEXT__ENABLED', true), + +]; 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/API/ContextAPI.php b/src/API/ContextAPI.php new file mode 100644 index 0000000..faa87a3 --- /dev/null +++ b/src/API/ContextAPI.php @@ -0,0 +1,177 @@ +getProjectRootDir(), + Framework::config()->pickBestChannels(false), + null, // the level will be decided by whatever is using ClarityContext + false, + false, + null, + ); + } + + + + /** + * Build a context object based on an exception, populating the settings that can't be updated later. + * + * @param Throwable $e The exception that occurred. + * @param boolean $isKnown Whether the exception has "known" values or not. + * @param integer|null $catcherObjectId The object-id of the Clarity instance that caught the exception. + * @return Context + * @throws ClarityContextInitialisationException When an invalid default reporting level is specified in the config. + */ + public static function buildContextFromException( + Throwable $e, + bool $isKnown = false, + ?int $catcherObjectId = null, + ): Context { + + $report = Framework::config()->getReport() + ?? true; + + $context = new Context( + $e, + null, + Support::getGlobalMetaCallStack(), + $catcherObjectId, + DataAPI::getTraceIdentifiers(), + Framework::config()->getProjectRootDir(), + Framework::config()->pickBestChannels($isKnown), + Framework::config()->pickBestLevel($isKnown), + $report, + false, + null, + ); + + self::rememberExceptionContext($e, $context); + + return $context; + } + + + + /** + * Associate a Context object to an exception. + * + * @param Throwable $exception The exception to associate to. + * @param Context $context The context to associate. + * @return void + */ + public static function rememberExceptionContext(Throwable $exception, Context $context): void + { + $objectId = spl_object_id($exception); + + $contexts = self::getGlobalExceptionContexts(); + $contexts[$objectId] = $context; + + self::setGlobalExceptionContexts($contexts); + } + + /** + * Retrieve an exception's Context object (won't create one when not found). + * + * @param Throwable $exception The exception to associate to. + * @return Context|null + */ + public static function getRememberedExceptionContext(Throwable $exception): ?Context + { + $objectId = spl_object_id($exception); + + return self::getGlobalExceptionContexts()[$objectId] + ?? null; + } + + /** + * Retrieve the Context object that was associated to an exception most recently. + * + * @return Context|null + */ + public static function getLatestExceptionContext(): ?Context + { + $contexts = self::getGlobalExceptionContexts(); + $objectIds = array_keys($contexts); + $objectId = end($objectIds); + return $contexts[$objectId] + ?? null; + } + + /** + * Forget an exception's Context object. + * + * @param Throwable $exception The exception to associate to. + * @return void + */ + public static function forgetExceptionContext(Throwable $exception): void + { + $objectId = spl_object_id($exception); + + $contexts = self::getGlobalExceptionContexts(); + unset($contexts[$objectId]); + + self::setGlobalExceptionContexts($contexts); + } + + + + + + /** + * Get the current exception-Contexts associations from global storage. + * + * @return array + */ + private static function getGlobalExceptionContexts(): array + { + /** @var array $return */ + $return = Framework::depInj()->get(InternalSettings::CONTAINER_KEY__EXCEPTION_CONTEXTS, []); + return $return; + } + + /** + * Set the exception-Contexts associations in global storage. + * + * @param array $contexts The stack of Context objects to store. + * @return void + */ + private static function setGlobalExceptionContexts(array $contexts): void + { + Framework::depInj()->set(InternalSettings::CONTAINER_KEY__EXCEPTION_CONTEXTS, $contexts); + } +} diff --git a/src/API/DataAPI.php b/src/API/DataAPI.php new file mode 100644 index 0000000..0fd648c --- /dev/null +++ b/src/API/DataAPI.php @@ -0,0 +1,50 @@ +get(InternalSettings::CONTAINER_KEY__DATA_STORE, []); + + /** @var array $identifiers */ + $identifiers = $data['trace-identifiers'] ?? []; + $identifiers[(string) $name] = $id; + $data['trace-identifiers'] = $identifiers; + + Framework::depInj()->set(InternalSettings::CONTAINER_KEY__DATA_STORE, $data); + } + + /** + * Retrieve the trace identifiers. + * + * @return array + */ + public static function getTraceIdentifiers(): array + { + /** @var mixed[] $data */ + $data = Framework::depInj()->get(InternalSettings::CONTAINER_KEY__DATA_STORE, []); + + /** @var array $identifiers */ + $identifiers = $data['trace-identifiers'] ?? []; + + return $identifiers; + } +} diff --git a/src/API/MetaCallStackAPI.php b/src/API/MetaCallStackAPI.php new file mode 100644 index 0000000..db8ebf1 --- /dev/null +++ b/src/API/MetaCallStackAPI.php @@ -0,0 +1,101 @@ +getEnabled()) { + return; + } + + if ($framesBack < 0) { + throw ClarityContextRuntimeException::invalidFramesBack($framesBack); + } + + Support::getGlobalMetaCallStack() + ->pushMultipleMetaDataValues($type, $identifier, [$metaData], $framesBack + 1, $removeTypesFromTop); + } + + /** + * Add multiple meta-data values to the "global" MetaCallStack (is quicker than adding each separately). + * + * @param string $type The "type" of meta-data to add. + * @param integer|string|null $identifier Required when updating meta-data later (can be shared). + * @param array $multipleMetaData An array of meta-data values to add. + * @param integer $framesBack The number of frames to go back, to get the intended caller + * frame. + * @param string[] $removeTypesFromTop The meta-data types to remove from the top of the stack. + * @return void + * @throws ClarityContextRuntimeException When an invalid number of steps to go back is given. + */ + public static function pushMultipleMetaData( + string $type, + int|string|null $identifier, + array $multipleMetaData, + int $framesBack = 0, + array $removeTypesFromTop = [], + ): void { + + if (!Framework::config()->getEnabled()) { + return; + } + + if ($framesBack < 0) { + throw ClarityContextRuntimeException::invalidFramesBack($framesBack); + } + + Support::getGlobalMetaCallStack() + ->pushMultipleMetaDataValues($type, $identifier, $multipleMetaData, $framesBack + 1, $removeTypesFromTop); + } + + /** + * Update some meta-data in the "global" MetaCallStack with a new value. + * + * @param string $type The "type" of meta-data to update. + * @param integer|string $identifier The identifier to find. + * @param string|mixed[] $replacementMetaData The replacement meta-data value. + * @return void + */ + public static function replaceMetaData( + string $type, + int|string $identifier, + string|array $replacementMetaData + ): void { + + if (!Framework::config()->getEnabled()) { + return; + } + + Support::getGlobalMetaCallStack() + ->replaceMetaDataValue($type, $identifier, $replacementMetaData); + } +} diff --git a/src/Clarity.php b/src/Clarity.php new file mode 100644 index 0000000..ae08f1a --- /dev/null +++ b/src/Clarity.php @@ -0,0 +1,88 @@ + $args */ + $args = func_get_args(); + MetaCallStackAPI::pushMultipleMetaData( + InternalSettings::META_DATA_TYPE__CONTEXT, + null, + $args, + 1, + [InternalSettings::META_DATA_TYPE__CONTROL_CALL], + ); + } + + + + /** + * Build a new Context object based on the current call stack (not based on an exception). + * + * @param integer $framesBack The number of frames to go back. + * @return Context + * @throws ClarityContextRuntimeException When an invalid number of steps to go back is given. + */ + public static function buildContextHere(int $framesBack = 0): Context + { + if ($framesBack < 0) { + throw ClarityContextRuntimeException::invalidFramesBack($framesBack); + } + + return ContextAPI::buildContextHere($framesBack + 1); + } + + + + /** + * Retrieve an exception's Context object (will create one when not found). + * + * Intended to be used by the framework's exception handler. + * + * @param Throwable $exception The exception to fetch the Context for. + * @return Context + * @throws ClarityContextInitialisationException When an invalid default reporting level is specified in the config. + */ + public static function getExceptionContext(Throwable $exception): Context + { + return ContextAPI::getRememberedExceptionContext($exception) + ?? ContextAPI::buildContextFromException($exception); // build new based on the exception + } + + + + /** + * Specify a trace identifier, for tracing the current request. + * + * (Multiple can be set with different names). + * + * @param string|integer $id The identifier to use. + * @param string|null $name An optional name for the identifier. + * @return void + */ + public static function traceIdentifier(string|int $id, string $name = null): void + { + DataAPI::traceIdentifier($id, $name); + } +} diff --git a/src/Context.php b/src/Context.php new file mode 100644 index 0000000..9f80d66 --- /dev/null +++ b/src/Context.php @@ -0,0 +1,794 @@ +|null $phpStackTrace The stack trace to use when there is no exception. + * @param MetaCallStack $metaCallStack The MetaCallStack object, which includes context details. + * @param integer|null $catcherObjectId The object-id of the Control instance that caught the + * exception. + * @param array $traceIdentifiers The trace identifiers. + * @param string $projectRootDir The project's root directory. + * @param string[] $channels The channels to log to. + * @param string|null $level The log reporting level to use. + * @param boolean $report Whether the exception should be reported or not. + * @param boolean|callable|Throwable $rethrow Whether the exception should be rethrown or not, a closure + * to resolve it, or the exception to rethrow itself. + * @param mixed $default The default value to return. + */ + public function __construct( + private ?Throwable $exception, + private ?array $phpStackTrace, + private MetaCallStack $metaCallStack, + private ?int $catcherObjectId, + private array $traceIdentifiers, + private string $projectRootDir, + private array $channels, + private ?string $level, + private bool $report, + private $rethrow, + private mixed $default, + ) { + } + + + + /** + * Get the exception that was thrown, that this Context object was built for. + * + * @return Throwable|null + */ + public function getException(): ?Throwable + { + return $this->exception; + } + + /** + * Get the call stack. + * + * @return CallStack + */ + public function getCallStack(): CallStack + { + $this->initialiseCallStack(); + + return clone $this->callStack; + } + + /** + * Get the stack trace (same as the call stack, but in reverse). + * + * @return CallStack + */ + public function getStackTrace(): CallStack + { + $this->initialiseCallStack(); + + $trace = clone $this->callStack; + $trace->reverse(); + return $trace; + } + + /** + * Get the trace identifiers. + * + * @return array + */ + public function getTraceIdentifiers(): array + { + return $this->traceIdentifiers; + } + + /** + * Get the known issues. + * + * @return string[] + */ + public function getKnown(): array + { + $this->initialiseCallStack(); + + return $this->knownSetByContext + ?? $this->known; + } + + /** + * Check if there are known issues. + * + * @return boolean + */ + public function hasKnown(): bool + { + return count($this->getKnown()) > 0; + } + + /** + * Get the channels to log to. + * + * @return string[] + */ + public function getChannels(): array + { + return $this->channels; + } + + /** + * Get the reporting level to use. + * + * @return string|null + */ + public function getLevel(): ?string + { + return $this->level; + } + + /** + * Check whether this exception should be reported or not. + * + * @return boolean + */ + public function getReport(): bool + { + return $this->report; + } + + /** + * Check whether this exception should be rethrown or not. + * + * @return boolean|callable|Throwable + */ + public function getRethrow(): bool|callable|Throwable + { + return $this->rethrow; + } + + /** + * Find out if the context details are worth listing. + * + * @return boolean + */ + public function detailsAreWorthListing(): bool + { + $this->initialiseCallStack(); + + return $this->detailsAreWorthListing; + } + + /** + * Get the default value to return. + * + * @return mixed + */ + public function getDefault(): mixed + { + return $this->default; + } + + + + /** + * Specify the trace identifier/s. + * + * @param array $traceIdentifiers The trace identifier/s. + * @return $this + */ + public function setTraceIdentifiers(array $traceIdentifiers): static + { + $this->traceIdentifiers = $traceIdentifiers; + + 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 + */ + public function setKnown(string|array $known, string|array ...$known2): static + { + /** @var string[] $known */ + $known = Support::normaliseArgs([], func_get_args()); + $this->knownSetByContext = $known; + + return $this; + } + + /** + * Specify the channels to log to. + * + * Note: This replaces any previous channels. + * + * @param string|string[] $channel The channel/s to log to. + * @param array ...$channel2 The channel/s to log to. + * @return $this + */ + public function setChannels(string|array $channel, string|array ...$channel2): self + { + /** @var string[] $channels */ + $channels = Support::normaliseArgs([], func_get_args()); + $this->channels = $channels; + + return $this; + } + + /** + * Specify the log reporting level. + * + * Note: This replaces the previous level. + * + * @param string|null $level The log-level to use. + * @return $this + */ + public function setLevel(?string $level): self + { + $this->level = $level; + + return $this; + } + + /** + * Set the log reporting level to "debug". + * + * @return $this + */ + public function debug(): static + { + $this->level = Settings::REPORTING_LEVEL_DEBUG; + + return $this; + } + + /** + * Set the log reporting level to "info". + * + * @return $this + */ + public function info(): static + { + $this->level = Settings::REPORTING_LEVEL_INFO; + + return $this; + } + + /** + * Set the log reporting level to "notice". + * + * @return $this + */ + public function notice(): static + { + $this->level = Settings::REPORTING_LEVEL_NOTICE; + + return $this; + } + + /** + * Set the log reporting level to "warning". + * + * @return $this + */ + public function warning(): static + { + $this->level = Settings::REPORTING_LEVEL_WARNING; + + return $this; + } + + /** + * Set the log reporting level to "error". + * + * @return $this + */ + public function error(): static + { + $this->level = Settings::REPORTING_LEVEL_ERROR; + + return $this; + } + + /** + * Set the log reporting level to "critical". + * + * @return $this + */ + public function critical(): static + { + $this->level = Settings::REPORTING_LEVEL_CRITICAL; + + return $this; + } + + /** + * Set the log reporting level to "alert". + * + * @return $this + */ + public function alert(): static + { + $this->level = Settings::REPORTING_LEVEL_ALERT; + + return $this; + } + + /** + * Set the log reporting level to "emergency". + * + * @return $this + */ + public function emergency(): static + { + $this->level = Settings::REPORTING_LEVEL_EMERGENCY; + + return $this; + } + + + + /** + * Specify that this exception should be reported (using the framework's reporting mechanism) or not. + * + * Note: This replaces the previous report setting. + * + * @param boolean $report Whether to report the exception or not. + * @return $this + */ + public function setReport(bool $report = true): self + { + $this->report = $report; + + return $this; + } + + /** + * Specify that this exception should not be reported (using the framework's reporting mechanism). + * + * Note: This replaces the previous report setting. + * + * @return $this + */ + public function dontReport(): self + { + $this->report = false; + + return $this; + } + + + + /** + * Specify whether this exception should be re-thrown or not. + * + * Note: This replaces the previous rethrow setting. + * + * @param boolean|callable|Throwable $rethrow Whether the exception should be rethrown or not, a closure to resolve + * it, or the exception to rethrow itself. + * @return $this + */ + public function setRethrow(bool|callable|Throwable $rethrow = true): self + { + $this->rethrow = $rethrow; + + return $this; + } + + /** + * Specify that this exception should not be re-thrown. + * + * Note: This replaces the previous rethrow setting. + * + * @return $this + */ + public function dontRethrow(): self + { + $this->rethrow = false; + + return $this; + } + + + + /** + * Suppress the exception - don't report and don't rethrow it. + * + * @return $this + */ + public function suppress(): self + { + $this->report = false; + $this->rethrow = false; + + return $this; + } + + + + /** + * Specify the default value that should be returned. + * + * @param mixed $default The default value to use. + * @return $this + */ + public function setDefault(mixed $default): self + { + $this->default = $default; + + return $this; + } + + + + + + /** + * Initialise the CallStack object. + * + * @return void + */ + private function initialiseCallStack(): void + { + if ($this->callStackInitialised) { + return; + } + $this->callStackInitialised = true; + + $this->buildCallStack(); + } + + /** + * Build the CallStack object that will be made available to the caller. + * + * @return void + */ + private function buildCallStack(): void + { + if ($this->exception) { + $callstack = $this->buildExceptionCallStack(); + $this->metaCallStack->pruneBasedOnExceptionCallStack($callstack); + } else { + $callstack = $this->buildPHPCallStack(); + $this->metaCallStack->pruneBasedOnRegularCallStack($callstack); + } + + $this->buildNewCallStack($callstack); + } + + /** + * Build a call stack array from the exception's stack trace. + * + * @return array + */ + private function buildExceptionCallStack(): array + { + /** @var Throwable $exception This method is only ever called when the exception exists. */ + $exception = $this->exception; + + $stackTrace = Support::preparePHPStackTrace( + $exception->getTrace(), + $exception->getFile(), + $exception->getLine() + ); + + $stackTrace = Support::pruneLaravelExceptionHandlerFrames($stackTrace); + + return array_reverse($stackTrace); + } + + /** + * Build a callstack array based on PHP's current stacktrace. + * + * @return array + */ + private function buildPHPCallStack(): array + { + $stackTrace = Support::preparePHPStackTrace($this->phpStackTrace ?? []); + + return array_reverse($stackTrace); + } + + + + /** + * Build a new CallStack, prepared with the correct frames and Meta objects. + * + * @param array $callstack The exception's callstack. + * @return void + */ + private function buildNewCallStack(array $callstack): void + { + $this->callStack = new CallStack( + $this->buildStackFrames($callstack) + ); + + // this is placed below the code above which generates a new callstack, + // because the callstack is still tracked, and callbacks are still run + if (!Framework::config()->getEnabled()) { + return; + } + + $this->insertLastApplicationFrameMeta(); + $this->insertExceptionThrownMeta(); + $this->insertExceptionCaughtMeta(); + + $this->collectAllKnownDetails(); + $this->decideIfDetailsAreWorthListing(); + } + + + + /** + * Combine the call stack and meta-data to build the stack-frames. + * + * @param array $callStack The exception's stack trace. + * @return Frame[] + */ + private function buildStackFrames(array $callStack): array + { + $stackMetaData = $this->metaCallStack->getStackMetaData(); + + $frames = []; + $isEnabled = Framework::config()->getEnabled(); + $count = 0; + $callMeta = null; + foreach ($callStack as $index => $frame) { + + $metaDataObjects = []; + $wasCaughtHere = false; + if ($isEnabled) { + + $metaDatas = $stackMetaData[$index] + ?? []; + + foreach ($metaDatas as $metaData) { + + $metaDataObject = $this->buildMetaObject($metaData); + $metaDataObjects[] = $metaDataObject; + + if ($metaDataObject instanceof CallMeta) { + $wasCaughtHere = $wasCaughtHere || $metaDataObject->wasCaughtHere(); + if ($wasCaughtHere) { + $callMeta = $metaDataObject; + } + } + } + } + + /** @var string $file */ + $file = $frame['file'] ?? ''; + + $projectFile = Support::resolveProjectFile($file, $this->projectRootDir); + $isApplicationFrame = Support::isApplicationFile($projectFile, $this->projectRootDir); + + $frames[] = new Frame( + $frame, + $projectFile, + $metaDataObjects, + $isApplicationFrame, + false, + ++$count == count($callStack), + false, + $wasCaughtHere, + ); + + if ($isApplicationFrame) { + $this->lastApplicationFrameIndex = $index; // store for later when applying the LastApplicationFrameMeta + } + if ($wasCaughtHere) { + $this->exceptionCaughtFrameIndex = $index; // store for later when applying the ExceptionCaughtMeta + if ($callMeta) { + // store for later when applying the ExceptionCaughtMeta + $this->exceptionCaughtByCallMeta = $callMeta; + } + } + } + + return $frames; + } + + /** + * Build a Meta object from the meta-data stored in the MetaCallStack object. + * + * @param array $metaData The meta-data stored in the MetaCallStack object. + * @return Meta + * @throws ClarityContextInitialisationException When the meta-data's type is invalid. + */ + private function buildMetaObject(array $metaData): Meta + { + /** @var string $type */ + $type = $metaData['type'] ?? ''; + + /** @var mixed[] $frameData */ + $frameData = $metaData['frame']; + + /** @var string $file */ + $file = $frameData['file'] ?? ''; + + $projectFile = Support::resolveProjectFile($file, $this->projectRootDir); + + switch ($type) { + + case InternalSettings::META_DATA_TYPE__CONTEXT: + /** @var string[] $context */ + $context = $metaData['value'] ?? ''; + + return new ContextMeta($frameData, $projectFile, $context); + + case InternalSettings::META_DATA_TYPE__CONTROL_CALL: + /** @var array $value */ + $value = $metaData['value'] ?? []; + /** @var string[] $known */ + $known = $value['known'] ?? []; + $objectId = $metaData['identifier'] ?? null; + // can ony "catch" an exception if there is an exception + $caughtHere = $this->exception && ($objectId === $this->catcherObjectId); + + return new CallMeta($frameData, $projectFile, $caughtHere, $known); + + default: + throw ClarityContextInitialisationException::invalidMetaType($type); + } + } + + + + + + /** + * Mark last application (i.e. non-vendor) frame with a LastApplicationFrameMeta. + * + * @return void + */ + private function insertLastApplicationFrameMeta(): void + { + $frameIndex = $this->lastApplicationFrameIndex; + if (is_null($frameIndex)) { + return; + } + + /** @var Frame $frame */ + $frame = $this->callStack[$frameIndex]; + + $meta = new LastApplicationFrameMeta($frame->getRawFrameData(), $frame->getProjectFile()); + + $this->callStack[$frameIndex] = $frame->buildCopyWithExtraMeta($meta, false, true); + } + + /** + * Mark the frame that threw the exception with a ExceptionThrownMeta. + * + * @return void + */ + private function insertExceptionThrownMeta(): void + { + if (!$this->exception) { + return; + } + + $frameIndex = count($this->callStack) - 1; // pick the last frame + + /** @var Frame $frame */ + $frame = $this->callStack[$frameIndex]; + + $meta = new ExceptionThrownMeta($frame->getRawFrameData(), $frame->getProjectFile()); + + $this->callStack[$frameIndex] = $frame->buildCopyWithExtraMeta($meta, true, false); + } + + /** + * Mark the frame that caught the exception with a ExceptionCaughtMeta. + * + * @return void + */ + private function insertExceptionCaughtMeta(): void + { + if (!$this->exception) { + return; + } + + $frameIndex = $this->exceptionCaughtFrameIndex; + if (is_null($frameIndex)) { + return; + } + + /** @var Frame $frame */ + $frame = $this->callStack[$frameIndex]; + + // ensure that the ExceptionCaughtMeta looks like it was caught by the CallMeta - i.e. same file + line + $frameData = $frame->getRawFrameData(); + $frameData['line'] = $this->exceptionCaughtByCallMeta->getLine(); + + $meta = new ExceptionCaughtMeta($frameData, $frame->getProjectFile()); + + $this->callStack[$frameIndex] = $frame->buildCopyWithExtraMeta($meta, false, false); + } + + + + + + /** + * Loop through the Meta objects, pick out the "known" details, and store for later. + * + * @return void + */ + private function collectAllKnownDetails(): void + { + $known = []; + /** @var CallMeta $meta */ + foreach ($this->callStack->getMeta(CallMeta::class) as $meta) { + $known = array_merge($known, $meta->getKnown()); + } + + $this->known = $known; + } + + /** + * Inspect the Meta objects, and decide if this Context object is worth reporting. + * + * (i.e. more than the "exception" and the "last application frame"). + * + * @return void + */ + private function decideIfDetailsAreWorthListing(): void + { + $metaTypeCounts = []; + foreach ($this->callStack->getMeta() as $meta) { + $metaTypeCounts[get_class($meta)] ??= 0; + $metaTypeCounts[get_class($meta)]++; + } + + $this->detailsAreWorthListing = Support::decideIfMetaCountsAreWorthListing($metaTypeCounts); + } +} diff --git a/src/Exceptions/ClarityContextException.php b/src/Exceptions/ClarityContextException.php new file mode 100644 index 0000000..20d65b7 --- /dev/null +++ b/src/Exceptions/ClarityContextException.php @@ -0,0 +1,12 @@ +initialiseConfig(); + + $this->app->bind(Context::class, fn() => ContextAPI::getLatestExceptionContext()); + } + + /** + * 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_CONTEXT__CONFIG_PATH, + InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME + ); + } + + /** + * Allow the default config to be published. + * + * @return void + */ + private function publishConfig(): void + { + $src = __DIR__ . '/..' . InternalSettings::LARAVEL_CONTEXT__CONFIG_PATH; + $dest = config_path(InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.php'); + + $this->publishes([$src => $dest], 'config'); + } +} diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..8ade601 --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,31 @@ + + * @implements ArrayAccess + * + * @codingStandardsIgnoreEnd + */ +class CallStack implements ArrayAccess, Countable, SeekableIterator +{ + /** @var Frame[] The CallStackFrames to use. */ + private array $frames; + + /** @var integer The current iteration position. */ + private int $pos = 0; + + /** @var boolean Keep track of whether the frames are reversed or not. */ + private bool $isReversed = false; + + /** @var Meta[] The Meta objects contained in these frames. */ + private array $meta; + + /** @var boolean Whether the Meta objects have been collected and cached or not. */ + private bool $initialisedMeta = false; + + + + /** + * Constructor. + * + * @param Frame[] $stack The Callstack frames. + */ + public function __construct(array $stack) + { + $this->frames = array_values($stack); + } + + + + /** + * Jump to a position. + * + * (SeekableIterator interface). + * + * @param integer $offset The offset to use. + * @return void + * @throws OutOfBoundsException When the offset is invalid. + */ + public function seek(int $offset): void + { + if (!array_key_exists($offset, $this->frames)) { + throw new OutOfBoundsException("Position $offset does not exist"); + } + + $this->pos = $offset; + } + + /** + * Return the current frame. + * + * (SeekableIterator interface). + * + * @return Frame|null + */ + public function current(): ?Frame + { + return $this->frames[$this->pos] + ?? null; + } + + /** + * Retrieve the current key. + * + * (SeekableIterator interface). + * + * @return integer + */ + public function key(): int + { + return $this->pos; + } + + /** + * Move to the next frame. + * + * (SeekableIterator interface). + * + * @return void + */ + public function next(): void + { + $this->pos++; + } + + /** + * Jump back to the first frame. + * + * (SeekableIterator interface). + * + * @return void + */ + public function rewind(): void + { + $this->pos = 0; + } + + /** + * Check if the current position is valid. + * + * (SeekableIterator interface). + * + * @return boolean + */ + public function valid(): bool + { + return $this->offsetExists($this->pos); + } + + + + /** + * Check if a position is valid. + * + * (ArrayAccess interface). + * + * @param mixed $offset The offset to check. + * @return boolean + */ + public function offsetExists(mixed $offset): bool + { + return is_int($offset) && array_key_exists($offset, $this->frames); + } + + /** + * Retrieve the value at a particular position. + * + * (ArrayAccess interface). + * + * @param mixed $offset The offset to retrieve. + * @return mixed + */ + public function offsetGet(mixed $offset): mixed + { + return $this->frames[$offset]; + } + + /** + * Set the value at a particular position. + * + * (ArrayAccess interface). + * + * @param mixed $offset The offset to update. + * @param mixed $value The value to set. + * @return void + * @throws InvalidArgumentException When an invalid value is given. + */ + public function offsetSet(mixed $offset, mixed $value): void + { + if (!$value instanceof Frame) { + throw new InvalidArgumentException("Invalid value - CallStack cannot store this value"); + } + + $this->frames[$offset] = $value; + } + + /** + * Remove the value from a particular position. + * + * (ArrayAccess interface). + * + * @param mixed $offset The offset to remove. + * @return void + */ + public function offsetUnset(mixed $offset): void + { + unset($this->frames[$offset]); + } + + + + /** + * Retrieve the number of frames. + * + * (Countable interface). + * + * @return integer + */ + public function count(): int + { + return count($this->frames); + } + + + + /** + * Reverse the callstack (so it looks like a backtrace). + * + * Resets the current position afterwards. + * + * @return $this + */ + public function reverse(): self + { + $this->frames = array_reverse($this->frames); + $this->isReversed = !$this->isReversed; + $this->rewind(); + + return $this; + } + + /** + * Retrieve the last application (i.e. non-vendor) frame (before the exception was thrown). + * + * @return Frame|null + */ + public function getLastApplicationFrame(): ?Frame + { + $frameIndex = $this->getLastApplicationFrameIndex(); + return !is_null($frameIndex) + ? $this->frames[$frameIndex] + : null; + } + + /** + * Retrieve the index of the last application (i.e. non-vendor) frame (before the exception was thrown). + * + * @return integer|null + */ + public function getLastApplicationFrameIndex(): ?int + { + $indexes = array_keys($this->frames); + foreach ($indexes as $index) { + + if (!$this->frames[$index]->isLastApplicationFrame()) { + continue; + } + + return $index; + } + return null; + } + + /** + * Retrieve the frame that threw the exception. + * + * @return Frame|null + */ + public function getExceptionThrownFrame(): ?Frame + { + $frameIndex = $this->getExceptionThrownFrameIndex(); + return !is_null($frameIndex) + ? $this->frames[$frameIndex] + : null; + } + + /** + * Retrieve the index of the frame that threw the exception. + * + * @return integer|null + */ + public function getExceptionThrownFrameIndex(): ?int + { + $indexes = array_keys($this->frames); + foreach ($indexes as $index) { + + if (!$this->frames[$index]->exceptionWasThrownHere()) { + continue; + } + + return $index; + } + return null; + } + + /** + * Retrieve the frame that caught the exception. + * + * @return Frame|null + */ + public function getExceptionCaughtFrame(): ?Frame + { + $frameIndex = $this->getExceptionCaughtFrameIndex(); + return !is_null($frameIndex) + ? $this->frames[$frameIndex] + : null; + } + + /** + * Retrieve the index of the frame that caught the exception. + * + * @return integer|null + */ + public function getExceptionCaughtFrameIndex(): ?int + { + $indexes = array_keys($this->frames); + foreach ($indexes as $index) { + + if (!$this->frames[$index]->exceptionWasCaughtHere()) { + continue; + } + + return $index; + } + return null; + } + + + + + + /** + * Get the Meta objects contained within the frames. + * + * @param array ...$class The type/s of meta-objects to get. Defaults to all meta-objects. + * @return Meta[] + */ + public function getMeta(string|array ...$class): array + { + $this->cacheAllMeta(); + + /** @var string[] $classes */ + $classes = Support::normaliseArgs([], $class); + if (!count($classes)) { + return $this->meta; + } + + $matchingMeta = []; + foreach ($this->meta as $meta) { + foreach ($classes as $class) { + if ($meta instanceof $class) { + $matchingMeta[] = $meta; + break; + } + } + } + + return $matchingMeta; + } + + /** + * Cache the Meta objects contained within the frames. + * + * @return void + */ + private function cacheAllMeta(): void + { + if ($this->initialisedMeta) { + return; + } + // @infection-ignore-all - "TrueValue - when switched to false, the meta-objects can be collected again, with + // the same result" + $this->initialisedMeta = true; + + $this->meta = []; + foreach ($this->frames as $frame) { + foreach ($frame->getMeta() as $meta) { + $this->meta[] = $meta; + } + } + } + + + + /** + * Get the meta-data, grouped nicely into MetaGroup objects. + * + * @param array ...$class The type/s of Meta objects to get. Defaults to all Meta objects. + * @return MetaGroup[] + */ + public function getMetaGroups(string|array ...$class): array + { + /** @var string[] $classes */ + $classes = Support::normaliseArgs([], $class); + + // process them all in callStack order, reverse the result later on + $frames = $this->isReversed + ? array_reverse($this->frames) + : $this->frames; + + $index = -1; + $lastFile = $lastLine = null; + $groupedMetaObjects = $mainFrames = []; + foreach ($frames as $frame) { + + foreach ($frame->getMeta($classes) as $meta) { + + // work out if a new group of Meta objects should be created + if ( + // if the frames are not similar + ($lastFile !== $meta->getFile()) + // or it's not on the same line, or the next + // (this ensures that LastApplicationFrameMeta and ExceptionThrownMeta from the frame inside the + // callable can be grouped with the code that calls Control. If other frames are on lines this + // close, it's probably ok to group them together for the purposes of the MetaGroup) + || (!in_array($meta->getLine(), [$lastLine, $lastLine + 1])) + ) { + // @infection-ignore-all - Increment - the order doesn't matter - a ksort can be added to cause the + // it to generate an exception, but I'd prefer not to add unnecessary code just to break a mutation + $index++; + $lastFile = $meta->getFile(); + } + + $lastLine = $meta->getLine(); + + $groupedMetaObjects[$index] ??= []; + $groupedMetaObjects[$index][] = $meta; + $mainFrames[$index] = $frame; + } + } +// ksort($groupedMetaObjects); the un-needed ksort that breaks the $index-- mutation above + + $metaGroups = []; + foreach (array_keys($groupedMetaObjects) as $index) { + $mainFrame = $mainFrames[$index]; + $firstMeta = $groupedMetaObjects[$index][0]; + $metaGroups[] = MetaGroup::newFromFrameAndMeta($mainFrame, $firstMeta, $groupedMetaObjects[$index]); + } + + return $this->isReversed + ? array_reverse($metaGroups) + : $metaGroups; + } +} diff --git a/src/Support/CallStack/Frame.php b/src/Support/CallStack/Frame.php new file mode 100644 index 0000000..a6e91a9 --- /dev/null +++ b/src/Support/CallStack/Frame.php @@ -0,0 +1,267 @@ +frameData['file'] + ?? ''; + } + + /** + * Get the frame's file, relative to the project-root. + * + * @return string + */ + public function getProjectFile(): string + { + /** @var string */ + return $this->projectFile; + } + + /** + * Get the frame's line. + * + * @return integer + */ + public function getLine(): int + { + /** @var integer */ + return $this->frameData['line'] + ?? 0; + } + + /** + * Get the frame's function. + * + * @return string + */ + public function getFunction(): string + { + /** @var string */ + return $this->frameData['function'] + ?? ''; + } + + /** + * Get the frame's class. + * + * @return string + */ + public function getClass(): string + { + /** @var string */ + return $this->frameData['class'] + ?? ''; + } + + /** + * Get the frame's object. + * + * @return object|null + */ + public function getObject(): ?object + { + /** @var object|null */ + return $this->frameData['object'] + ?? null; + } + + /** + * Get the frame's type. + * + * @return string + */ + public function getType(): string + { + /** @var string */ + return $this->frameData['type'] + ?? ''; + } + + /** + * Get the frame's args. + * + * @return mixed[]|null + */ + public function getArgs(): ?array + { + /** @var mixed[]|null */ + return $this->frameData['args'] + ?? null; + } + + + + /** + * Get the meta-data that was defined in this frame. + * + * @param string|string[] $class The type/s of meta-objects to get. Defaults to all meta-objects. + * @param string|string[] ...$class2 The type/s of meta-objects to get. Defaults to all meta-objects. + * @return Meta[] + */ + public function getMeta(string|array $class = null, string|array ...$class2): array + { + $classes = Support::normaliseArgs([], func_get_args()); + if (!count($classes)) { + return $this->meta; + } + + $matchingMeta = []; + foreach ($this->meta as $meta) { + foreach ($classes as $class) { + if ($meta instanceof $class) { + $matchingMeta[] = $meta; + break; + } + } + } + return $matchingMeta; + } + + + + /** + * Find out if this is an application (i.e. non-vendor) frame. + * + * @return boolean + */ + public function isApplicationFrame(): bool + { + return $this->isApplicationFrame; + } + + /** + * Find out if this is the last application (i.e. non-vendor) frame. + * + * @return boolean + */ + public function isLastApplicationFrame(): bool + { + return $this->isLastApplicationFrame; + } + + /** + * Find out if this is a frame from the vendor directory. + * + * @return boolean|null + */ + public function isVendorFrame(): ?bool + { + return !$this->isApplicationFrame; + } + + /** + * Find out if this is the last frame. + * + * @return boolean + */ + public function isLastFrame(): bool + { + return $this->isLastFrame; + } + + /** + * Find out if the exception was thrown by this frame or not. + * + * @return boolean + */ + public function exceptionWasThrownHere(): bool + { + return $this->thrownHere; + } + + /** + * Find out if the exception was caught by this frame or not. + * + * @return boolean + */ + public function exceptionWasCaughtHere(): bool + { + return $this->caughtHere; + } + + + + + + /** + * Build a copy of this CallStackFrame, with an extra Meta object added to it. + * + * @internal + * + * @param Meta $newMeta The new Meta object to add. + * @param boolean $thrownHere Whether the exception was thrown by this frame or not. + * @param boolean $isLastApplicationFrame Whether this is the last application frame or not. + * @return self + */ + public function buildCopyWithExtraMeta( + Meta $newMeta, + bool $thrownHere, + bool $isLastApplicationFrame + ): self { + + return new self( + $this->frameData, + $this->projectFile, + array_merge($this->getMeta(), [$newMeta]), + $this->isApplicationFrame, + $this->isLastApplicationFrame || $isLastApplicationFrame, + $this->isLastFrame, + $this->thrownHere || $thrownHere, + $this->caughtHere, + ); + } + + /** + * Retrieve the raw debug_backtrace() frame data. + * + * @internal + * + * @return mixed[] + */ + public function getRawFrameData(): array + { + return $this->frameData; + } +} diff --git a/src/Support/CallStack/MetaData/CallMeta.php b/src/Support/CallStack/MetaData/CallMeta.php new file mode 100644 index 0000000..1d36909 --- /dev/null +++ b/src/Support/CallStack/MetaData/CallMeta.php @@ -0,0 +1,47 @@ +caughtHere; + } + + /** + * Get the "known" details about the exception. + * + * @return string[] + */ + public function getKnown(): array + { + return $this->known; + } +} diff --git a/src/Support/CallStack/MetaData/ContextMeta.php b/src/Support/CallStack/MetaData/ContextMeta.php new file mode 100644 index 0000000..72f4e08 --- /dev/null +++ b/src/Support/CallStack/MetaData/ContextMeta.php @@ -0,0 +1,35 @@ +context; + } +} diff --git a/src/Support/CallStack/MetaData/ExceptionCaughtMeta.php b/src/Support/CallStack/MetaData/ExceptionCaughtMeta.php new file mode 100644 index 0000000..7618775 --- /dev/null +++ b/src/Support/CallStack/MetaData/ExceptionCaughtMeta.php @@ -0,0 +1,21 @@ +frameData['file'] + ?? ''; + } + + /** + * Get the frame's file, relative to the project-root. + * + * @return string + */ + public function getProjectFile(): string + { + /** @var string */ + return $this->projectFile; + } + + /** + * Get the frame's line. + * + * @return integer + */ + public function getLine(): int + { + /** @var integer */ + return $this->frameData['line'] + ?? 0; + } + + /** + * Get the frame's function. + * + * @return string + */ + public function getFunction(): string + { + /** @var string */ + return $this->frameData['function'] + ?? ''; + } + + /** + * Get the frame's class. + * + * @return string + */ + public function getClass(): string + { + /** @var string */ + return $this->frameData['class'] + ?? ''; + } + +// /** +// * Get the frame's object. +// * +// * @return object|null +// */ +// public function getObject(): ?object +// { +// /** @var object|null */ +// return $this->frameData['object'] +// ?? null; +// } + + /** + * Get the frame's type. + * + * @return string + */ + public function getType(): string + { + /** @var string */ + return $this->frameData['type'] + ?? ''; + } + +// /** +// * Get the frame's args. +// * +// * @return mixed[]|null +// */ +// public function getArgs(): ?array +// { +// /** @var mixed[]|null */ +// return $this->frame['args'] +// ?? null; +// } +} diff --git a/src/Support/CallStack/MetaGroup.php b/src/Support/CallStack/MetaGroup.php new file mode 100644 index 0000000..07c5504 --- /dev/null +++ b/src/Support/CallStack/MetaGroup.php @@ -0,0 +1,225 @@ +getFile(), + $firstMeta->getProjectFile(), + $firstMeta->getLine(), + $firstMeta->getFunction(), + $firstMeta->getClass(), + $firstMeta->getType(), + $metaObjects, + $frame->isApplicationFrame(), + $frame->isLastApplicationFrame(), + $frame->isLastFrame(), + $frame->exceptionWasThrownHere(), + $frame->exceptionWasCaughtHere(), + ); + } + + + + /** + * Get the frame's file. + * + * @return string + */ + public function getFile(): string + { + return $this->file; + } + + /** + * Get the frame's file, relative to the project-root. + * + * @return string + */ + public function getProjectFile(): string + { + return $this->projectFile; + } + + /** + * Get the frame's line. + * + * @return integer + */ + public function getLine(): int + { + return $this->line; + } + + /** + * Get the frame's function. + * + * @return string + */ + public function getFunction(): string + { + return $this->function; + } + + /** + * Get the frame's class. + * + * @return string + */ + public function getClass(): string + { + return $this->class; + } + +// /** +// * Get the frame's object. +// * +// * @return object|null +// */ +// public function getObject(): ?object +// { +// return $this->object; +// } + + /** + * Get the frame's type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + +// /** +// * Get the frame's args. +// * +// * @return mixed[]|null +// */ +// public function getArgs(): ?array +// { +// return $this->args; +// } + + /** + * Get the Meta objects in this group. + * + * @return Meta[] + */ + public function getMeta(): array + { + return $this->meta; + } + + + + /** + * Find out if this meta-group is in an application (i.e. non-vendor) frame. + * + * @return boolean + */ + public function isInApplicationFrame(): bool + { + return $this->isInApplicationFrame; + } + + /** + * Find out if this meta-group is the last application (i.e. non-vendor) frame. + * + * @return boolean + */ + public function isInLastApplicationFrame(): bool + { + return $this->isInLastApplicationFrame; + } + + /** + * Find out if this meta-group is in a vendor frame. + * + * @return boolean + */ + public function isInVendorFrame(): bool + { + return !$this->isInApplicationFrame; + } + + /** + * Find out if this meta-group is in the last frame. + * + * @return boolean + */ + public function isInLastFrame(): bool + { + return $this->isInLastFrame; + } + + /** + * Find out if the exception was thrown in the frame this meta-group is from. + * + * @return boolean + */ + public function exceptionThrownInThisFrame(): bool + { + return $this->exceptionThrownInThisFrame; + } + + /** + * Find out if the exception was caught in the frame this meta-group is from. + * + * @return boolean + */ + public function exceptionCaughtInThisFrame(): bool + { + return $this->exceptionCaughtInThisFrame; + } +} diff --git a/src/Support/Environment.php b/src/Support/Environment.php new file mode 100644 index 0000000..52824a0 --- /dev/null +++ b/src/Support/Environment.php @@ -0,0 +1,19 @@ +make($key); + + } catch (BindingResolutionException) { + } + + return is_callable($default) + ? $app->call($default) + : $default; + } + + /** + * Get a value (or class instance) using the dependency container. Will store the default when not present. + * + * @param string $key The key to retrieve. + * @param mixed $default The default value to fall back to (will be executed when callable). + * @return mixed + */ + public static function getOrSet(string $key, mixed $default): mixed + { + /** @var Application $app */ + $app = app(); + + try { + + $return = $app->make($key); + + if (!is_null($return)) { + return $return; + } + + } catch (BindingResolutionException) { + } + + $return = is_callable($default) + ? $app->call($default) + : $default; + + self::set($key, $return); + + return $return; + } + + /** + * Store a value or class instance in the dependency container. + * + * @param string $key The key to set. + * @param mixed $value The value to set. + * @return void + */ + public static function set(string $key, mixed $value): void + { + /** @var Application $app */ + $app = app(); + + // @infection-ignore-all - Ternary - both scoped(..) and singleton(..) give the same result in the context of + // tests + method_exists($app, 'scoped') + ? $app->scoped($key, fn() => $value) + : $app->singleton($key, fn() => $value); + } + + /** + * Create a concrete instance of a class using the dependency container. + * + * @param string $abstract The class to instantiate. + * @param mixed[] $parameters The constructor parameters to pass. + * @return mixed + */ + public static function make(string $abstract, array $parameters = []): mixed + { + /** @var Application $app */ + $app = app(); + return $app->make($abstract, $parameters); + } + + /** + * Run a callable, resolving parameters first using the dependency container. + * + * @param callable $callable The callable to run. + * @param mixed[] $parameters The parameters to pass. + * @return mixed + */ + public static function call(callable $callable, array $parameters = []): mixed + { + /** @var Application $app */ + $app = app(); + return $app->call($callable, $parameters); + } +} diff --git a/src/Support/Framework/Framework.php b/src/Support/Framework/Framework.php new file mode 100644 index 0000000..89bfc84 --- /dev/null +++ b/src/Support/Framework/Framework.php @@ -0,0 +1,91 @@ + The current PHP callstack. */ + private array $callStack = []; + + /** @var array> The meta-data that's been linked to points in the callstack. */ + private array $stackMetaData = []; + + + + /** + * Get the callstack's meta-data. + * + * @return array> + */ + public function getStackMetaData(): array + { + return $this->stackMetaData; + } + + + + + + /** + * Add some meta-data to the callstack. + * + * @param string $type The type of meta-data to save. + * @param integer|string|null $identifier Required when updating meta-data later (can be shared). + * @param array $multipleMetaData The meta-data values to save. + * @param integer $framesBack The number of frames to go back, to get the intended caller + * frame. + * @param string[] $removeTypesFromTop The meta-data types to remove from the top of the stack. + * @return void + * @throws ClarityContextRuntimeException When an invalid number of steps to go back is given. + */ + public function pushMultipleMetaDataValues( + string $type, + int|string|null $identifier, + array $multipleMetaData, + int $framesBack, + array $removeTypesFromTop = [], + ): void { + + $phpStackTrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS); + $phpStackTrace = Support::stepBackStackTrace($phpStackTrace, $framesBack); + $stackTrace = Support::preparePHPStackTrace($phpStackTrace); + $callstack = array_reverse($stackTrace); + + $this->replaceCallStack($callstack); + + $lastFrame = array_pop($callstack) ?? []; + + $this->removeMetaDataFromTop($removeTypesFromTop); + + foreach (array_values($multipleMetaData) as $paramCount => $oneMetaData) { + $this->recordMetaData($type, $identifier, $oneMetaData, $paramCount, $lastFrame); + } + } + +// /** +// * Find particular existing meta-data throughout the callstack, and replace it. +// * +// * @param string $type The type of meta-data to update. +// * @param string $field The field to check when searching. +// * @param mixed $find The value to find when searching. +// * @param mixed[] $newValue The replacement meta-data. +// * @return void +// */ +// public function replaceMetaDataValue(string $type, string $field, mixed $find, array $newValue): void +// { +// foreach (array_keys($this->stackMetaData) as $frameIndex) { +// foreach (array_keys($this->stackMetaData[$frameIndex]) as $index) { +// +// if ($this->stackMetaData[$frameIndex][$index]['type'] != $type) { +// continue; +// } +// +// if (!array_key_exists($field, $this->stackMetaData[$frameIndex][$index]['value'])) { +// continue; +// } +// +// if ($this->stackMetaData[$frameIndex][$index]['value'][$field] !== $find) { +// continue; +// } +// +// $this->stackMetaData[$frameIndex][$index]['value'] = $newValue; +// +// return; +// } +// } +// } + + /** + * Find particular existing meta-data throughout the callstack, and replace it/them. + * + * @param string $type The "type" of meta-data to update. + * @param integer|string $identifier The identifier to find. + * @param string|mixed[] $replacementMetaData The replacement meta-data value. + * @return void + */ + public function replaceMetaDataValue( + string $type, + int|string $identifier, + string|array $replacementMetaData, + ): void { + + foreach (array_keys($this->stackMetaData) as $frameIndex) { + foreach (array_keys($this->stackMetaData[$frameIndex]) as $index) { + + if ($this->stackMetaData[$frameIndex][$index]['identifier'] !== $identifier) { + continue; + } + + if ($this->stackMetaData[$frameIndex][$index]['type'] != $type) { + continue; + } + + $this->stackMetaData[$frameIndex][$index]['value'] = $replacementMetaData; + } + } + } + + + + + + /** + * Store the current stack, and purge any stack-content that doesn't sit inside it anymore. + * + * @param array $newCallStack The new callstack to store. + * @return void + */ + private function replaceCallStack(array $newCallStack): void + { + $this->pruneBasedOnRegularCallStack($newCallStack); + + $this->callStack = $newCallStack; + } + + + + + + /** + * Prune off meta-data based on an exception callstack. + * + * @param array $exceptionCallStack The callstack to prune against. + * @return void + */ + public function pruneBasedOnExceptionCallStack(array $exceptionCallStack): void + { + $this->pruneMetaDataFromRemovedFrames($exceptionCallStack, ['file', 'line']); +// $this->pruneMetaDataThatDontBelongToTheirFrameAnymore($exceptionCallStack); + } + + /** + * Prune off meta-data based on a regular callstack. + * + * @param array $newCallStack The callstack to prune against. + * @return void + */ + public function pruneBasedOnRegularCallStack(array $newCallStack): void + { + $this->pruneMetaDataFromRemovedFrames($newCallStack); +// $this->pruneMetaDataThatDontBelongToTheirFrameAnymore($newCallStack); + } + + + + /** + * Remove meta-data that should be pruned. + * + * @param array $newCallStack The new callstack to compare against. + * @param string[] $fieldsToCompare The fields from each frame to compare (whole frames are compared + * by default). + * @return void + */ + private function pruneMetaDataFromRemovedFrames(array $newCallStack, array $fieldsToCompare = []): void + { + $prunableFrames = $this->findPrunableFrames($this->callStack, $newCallStack, $fieldsToCompare); + + foreach ($prunableFrames as $frameIndex) { + unset($this->stackMetaData[$frameIndex]); + } + } + + /** + * Compare two callstacks, and work out which frames from the old stack needs pruning. + * + * Returns the frames' indexes. + * + * @param array $oldCallStack The old stack to compare. + * @param array $newCallStack The new stack to compare. + * @param string[] $fieldsToCompare The fields from each frame to compare (whole frames are compared + * by default). + * @return integer[] + */ + private function findPrunableFrames(array $oldCallStack, array $newCallStack, array $fieldsToCompare = []): array + { + $diffPos = count($fieldsToCompare) + ? $this->findDiffPosCompareFields($oldCallStack, $newCallStack, $fieldsToCompare) + : $this->findDiffPos($oldCallStack, $newCallStack); + + return array_slice(array_keys($this->callStack), $diffPos, null, true); + } + + /** + * Find the first position of the two arrays where their values are different. + * + * @param array $oldStack The old stack to compare. + * @param array $newStack The new stack to compare. + * @return integer + */ + private function findDiffPos(array $oldStack, array $newStack): int + { + if (!count($oldStack)) { + return 0; + } + + // find the first frame that's different + $index = 0; + foreach ($newStack as $index => $newFrame) { + if ($newFrame !== ($oldStack[$index] ?? null)) { + break; + } + } + + $newFrame = $newStack[$index] ?? []; + $oldFrame = $oldStack[$index] ?? []; + + // check what was different… + // when any of these are different, the frame must be different + foreach (['file', 'object', 'function', 'class', 'type'] as $field) { + if (($newFrame[$field] ?? null) !== ($oldFrame[$field] ?? null)) { + return $index; + } + } + + // the line number is the only field left. + // given that's the only thing that's different, it's likely to be within the same frame + // (this wouldn't be the case if a frame in the same class called a method (or closure), then from the same line + // called another method (or closure) in the same class. Unfortunately PHP doesn't differentiate between + // them in debug_stacktrace()) + return $index + 1; + } + + /** + * Find the first position of the two arrays where their values are different. Compares particular keys from each. + * + * This method is available so the stack trace taken from an exception (whose frames don't contain "object" fields) + * can be compared to a debug_backtrace() backtrace. + * + * @param array $oldCallStack The old stack to compare. + * @param array $newCallStack The new stack to compare. + * @param string[] $fieldsToCompare The fields from each frame to compare. + * @return integer + */ + private function findDiffPosCompareFields( + array $oldCallStack, + array $newCallStack, + array $fieldsToCompare = [], + ): int { + + // find the first frame that's different + $index = 0; + foreach ($newCallStack as $index => $newFrame) { + + if (!array_key_exists($index, $oldCallStack)) { + break; + } + $oldFrame = $oldCallStack[$index]; + + foreach ($fieldsToCompare as $field) { + if (($newFrame[$field] ?? null) !== ($oldFrame[$field] ?? null)) { + break 2; + } + } + } + + // check what was different… + // when any of these are different, the frame must be different + $fields = array_intersect(['file', 'object', 'function', 'class', 'type'], $fieldsToCompare); + foreach ($fields as $field) { + if (($newFrame[$field] ?? null) !== ($oldFrame[$field] ?? null)) { + return $index; + } + } + + // the line number is the only field left + // given that's the only thing that's different, it's likely to be within the same frame + return $index + 1; + } + + + +// /** +// * Check to make sure that each Meta object belongs to a frame with the same object id. +// * +// * @param array $newCallStack The new callstack that will be stored soon. +// * @return void +// */ +// private function pruneMetaDataThatDontBelongToTheirFrameAnymore(array $newCallStack): void +// { +// foreach (array_keys($this->stackMetaData) as $frameIndex) { +// foreach (array_keys($this->stackMetaData[$frameIndex]) as $index) { +// +// /** @var mixed[] $frameData */ +// $frameData = $this->stackMetaData[$frameIndex][$index]['frame']; +// +// $objectId = $frameData['object'] +// ?? null; +// $frameObjectId = $newCallStack[$frameIndex]['object'] +// ?? null; +// +// if ($objectId !== $frameObjectId) { +// unset($this->stackMetaData[$frameIndex][$index]); +// } +// } +// +// // re-index so the indexes are sequential +// // so that resolveMetaDataIndexToUse() below doesn't have trouble when determining the next index to use +// $this->stackMetaData[$frameIndex] = array_values($this->stackMetaData[$frameIndex]); +// } +// } + + + + + + /** + * Record some meta-data, at the current point in the stack. + * + * @param string $type The type of meta-data to save. + * @param integer|string|null $identifier Required when updating meta-data later (can be shared). + * @param mixed $value The value to save. + * @param integer $paramCount The parameter number this context was. + * @param mixed[] $frameData The number of frames to go back, to get the intended caller frame. + * @return void + */ + private function recordMetaData( + string $type, + int|string|null $identifier, + mixed $value, + int $paramCount, + array $frameData, + ): void { + + /** @var integer $frameIndex */ + $frameIndex = array_key_last($this->callStack); + + $this->stackMetaData[$frameIndex] ??= []; + + $line = is_int($frameData['line'] ?? null) ? $frameData['line'] : null; + + $index = $this->resolveMetaDataIndexToUse($frameIndex, $type, $line, $paramCount); + + $newMetaData = [ + 'type' => $type, + 'identifier' => $identifier, + 'paramCount' => $paramCount, + 'frame' => $frameData, + 'value' => $value, + ]; + + array_splice($this->stackMetaData[$frameIndex], $index, 0, [$newMetaData]); + } + + /** + * Determine the position in the stackMetaData array to update. + * + * If meta-data was defined before on the same line, it will be updated. + * + * This allows for the code inside a loop to update its meta-data, instead of continually adding more. + * + * @param integer $frameIndex The index of the stackContent array. + * @param string $type The type of meta-data to save. + * @param integer|null $line The line that made the call. + * @param integer $paramCount The parameter number this context was. + * @return integer + */ + private function resolveMetaDataIndexToUse(int $frameIndex, string $type, ?int $line, int $paramCount): int + { + // search for the same entry from before + // if found, remove it, and remove other similar ones (ones from subsequent "parameters" on the same line) + $firstIndex = null; + foreach ($this->stackMetaData[$frameIndex] as $index => $metaData) { + + if (!$this->looksLikeSameFrame($metaData, $type, $line)) { + continue; + } + + // don't remove parameters that came before this one + if ($metaData['paramCount'] < $paramCount) { + continue; + } + + $firstIndex ??= $index; + + // remove this meta-data, and ones after it + unset($this->stackMetaData[$frameIndex][$index]); + } + + // the position was found, return that + if (!is_null($firstIndex)) { + $this->stackMetaData[$frameIndex] = array_values($this->stackMetaData[$frameIndex]); // re-index + return $firstIndex; + } + + + + // scan for similar ones and pick the correct spot to insert into next to those + $lastSimilarIndex = null; + foreach ($this->stackMetaData[$frameIndex] as $index => $metaData) { + + if (!$this->looksLikeSameFrame($metaData, $type, $line)) { + continue; + } + + $lastSimilarIndex = $index; + } + + if (!is_null($lastSimilarIndex)) { + return $lastSimilarIndex + 1; + } + + + + // add it to the end + return count($this->stackMetaData[$frameIndex]); + } + + /** + * Check to see if this a type and line matches an existing meta-data. + * + * @param mixed[] $metaData The existing meta-data to check. + * @param string $type The type of meta-data to search for. + * @param integer|null $line The line number the new meta-data is on. + * @return boolean + */ + private function looksLikeSameFrame(array $metaData, string $type, ?int $line): bool + { + if ($metaData['type'] !== $type) { + return false; + } + + // it *has* to be the same file, as it's the same frame in the callstack (already resolved) +// if (($metaData['frame']['file'] ?? null) !== $file) { +// return false; +// } + + if (($metaData['frame']['line'] ?? null) !== $line) { + return false; + } + + return true; + } + + + + + + /** + * Remove existing meta-data from the top of the callstack. + * + * @param string[] $types The types of meta-data to remove. + * @return void + */ + private function removeMetaDataFromTop(array $types): void + { + if (!count($types)) { + return; + } + + $lastFrame = max(array_keys($this->callStack)); + if (!array_key_exists($lastFrame, $this->stackMetaData)) { + return; + } + + foreach (array_keys($this->stackMetaData[$lastFrame]) as $index) { + if (in_array($this->stackMetaData[$lastFrame][$index]['type'], $types, true)) { + unset($this->stackMetaData[$lastFrame][$index]); + } + } + + // re-index so the indexes are sequential + // so that resolveMetaDataIndexToUse() above doesn't have trouble when determining the next index to use + $this->stackMetaData[$lastFrame] = array_values($this->stackMetaData[$lastFrame]); + } +} diff --git a/src/Support/Support.php b/src/Support/Support.php new file mode 100644 index 0000000..0e04b4a --- /dev/null +++ b/src/Support/Support.php @@ -0,0 +1,259 @@ + $metaTypeCounts The counts of each type of Meta class. + * @return boolean + */ + public static function decideIfMetaCountsAreWorthListing(array $metaTypeCounts): bool + { + ksort($metaTypeCounts); + + $notWorthReporting = []; + $notWorthReporting[] = []; + $notWorthReporting[] = [LastApplicationFrameMeta::class => 1]; + $notWorthReporting[] = [ExceptionThrownMeta::class => 1]; + $notWorthReporting[] = [ExceptionThrownMeta::class => 1, LastApplicationFrameMeta::class => 1]; + + return !in_array($metaTypeCounts, $notWorthReporting, true); + } + + + + /** + * Get the MetaCallStack from global storage (creates and stores a new one if it hasn't been set yet). + * + * @internal + * + * @return MetaCallStack + */ + public static function getGlobalMetaCallStack(): MetaCallStack + { + /** @var MetaCallStack $return */ + $return = Framework::depInj()->getOrSet( + InternalSettings::CONTAINER_KEY__META_CALL_STACK, + fn() => new MetaCallStack() + ); + + return $return; + } + + + + /** + * Remove x number of the top-most stack frames, so the intended caller frame is at the "top". + * + * @internal + * + * @param array $phpStackTrace The stack trace to alter. + * @param integer $framesBack The number of frames to go back, to get the intended caller frame. + * @return array + * @throws ClarityContextRuntimeException When an incorrect number of $framesBack is given. + */ + public static function stepBackStackTrace(array $phpStackTrace, int $framesBack): array + { + if ($framesBack < 0) { + throw ClarityContextRuntimeException::invalidFramesBack($framesBack); + } + + if ($framesBack >= count($phpStackTrace)) { + throw ClarityContextRuntimeException::tooManyFramesBack($framesBack, count($phpStackTrace)); + } + + // go back x number of steps + // + // @infection-ignore-all - LessThanNegotiation & Increment - prevents timeout - of the loop below + for ($count = 0; $count < $framesBack; $count++) { + array_shift($phpStackTrace); + } + + return $phpStackTrace; + } + + /** + * Resolve the current PHP callstack. Then tweak it, so it's in a format that's good for comparing. + * + * @internal + * + * @param array $phpStackTrace The stack trace to alter. + * @param string|null $file The first file to shift onto the beginning. + * @param integer|null $line The first line to shift onto the beginning. + * @return array + */ + public static function preparePHPStackTrace( + array $phpStackTrace, + ?string $file = null, + ?int $line = null + ): array { + + // shift the file and line values by 1 frame + $newStackTrace = []; + foreach ($phpStackTrace as $frame) { + + $nextFile = $frame['file'] ?? ''; + $nextLine = $frame['line'] ?? 0; + + $frame['file'] = $file; + $frame['line'] = $line; + $newStackTrace[] = $frame; + + $file = $nextFile; + $line = $nextLine; + } + + $newStackTrace[] = [ + 'file' => $file, + 'line' => $line, + 'function' => '[top]', + 'args' => [], + ]; + + // a very edge case… + // + // e.g. call_user_func_array([new Context(), 'add'], ['something']); + // + // when Context methods are called via call_user_func_array(..), the callstack's most recent frame is an extra + // frame that's missing the "file" and "line" keys + // + // this causes clarity not to remember meta-data, because it's associated to a "phantom" frame that's forgotten + // the moment the callstack is inspected next + // + // skipping this frame brings the most recent frame back to the place where call_user_func_array was called + // + // @infection-ignore-all - FunctionCallRemoval - prevents timeout of array_shift(..) below + while ( + (count($newStackTrace)) + && (($newStackTrace[0]['file'] == '') || ($newStackTrace[0]['line'] == 0)) + ) { + array_shift($newStackTrace); + } + + // turn objects into spl_object_ids + // - so we're not unnecessarily holding on to references to these objects (in case that matters for the caller), + // - and to reduce memory requirements + foreach ($newStackTrace as $index => $step) { + + $object = $step['object'] + ?? null; + + if (is_object($object)) { + $newStackTrace[$index]['object'] = spl_object_id($step['object']); + } + + // remove the args, as they can cause unnecessary memory usage during runs of the test-suite + // this happens when there are a lot of tests, as phpunit can pass large arrays of arg values + unset($newStackTrace[$index]['args']); + } + + return $newStackTrace; + } + + /** + * Remove Laravel's exception handler methods from the top of the stack trace. + * + * @internal + * + * @param array $stackTrace The stack trace to alter. + * @return array + */ + public static function pruneLaravelExceptionHandlerFrames(array $stackTrace): array + { + if (!count($stackTrace)) { + return []; + } + + $class = is_string($stackTrace[0]['class'] ?? null) ? $stackTrace[0]['class'] : ''; + + while (str_starts_with($class, 'Illuminate\Foundation\Bootstrap\HandleExceptions')) { + array_shift($stackTrace); + + $class = is_string($stackTrace[0]['class'] ?? null) ? $stackTrace[0]['class'] : ''; + } + + return $stackTrace; + } +} diff --git a/tests/Integration/ClarityIntegrationTest.php b/tests/Integration/ClarityIntegrationTest.php new file mode 100644 index 0000000..f3bf0a2 --- /dev/null +++ b/tests/Integration/ClarityIntegrationTest.php @@ -0,0 +1,42 @@ +getCallStack()->getMeta(ContextMeta::class); + $contextMeta = $contextMetas[0]; + + $method = 'test_that_meta_data_is_remembered_when_calling_call_user_func_array'; + $class = 'CodeDistortion\ClarityContext\Tests\Integration\ClarityIntegrationTest'; + + self::assertCount(1, $contextMetas); + self::assertSame(__FILE__, $contextMeta->getFile()); + self::assertSame(__LINE__ - 11, $contextMeta->getLine()); + self::assertSame($method, $contextMeta->getFunction()); + self::assertSame($class, $contextMeta->getClass()); + } +} diff --git a/tests/Integration/Support/API/ContextAPIIntegrationTest.php b/tests/Integration/Support/API/ContextAPIIntegrationTest.php new file mode 100644 index 0000000..cb99f6e --- /dev/null +++ b/tests/Integration/Support/API/ContextAPIIntegrationTest.php @@ -0,0 +1,114 @@ +getStackTrace()->count()); + + // check the channels that were resolved + self::assertSame(['channels-when-not-known'], $context->getChannels()); + + // check the level that was resolved + self::assertNull($context->getLevel()); + + // check the report and rethrow settings + self::assertSame(false, $context->getReport()); + self::assertSame(false, $context->getRethrow()); + + // check that the CallMeta doesn't "catch" the exception (because no exception exists) + $callMeta = $context->getCallStack()->getMeta(CallMeta::class)[0] ?? null; + self::assertInstanceOf(CallMeta::class, $callMeta); // the CallMeta will exist + self::assertFalse($callMeta->wasCaughtHere()); // but it won't have "caught" the exception, because + // there was no exception + } + + + + /** + * Test that the Context object that ContextAPI builds (based on an exception) contains the required values. + * + * @test + * + * @return void + */ + public static function test_build_context_method_for_an_exception_and_check_output_context_object(): void + { + // add some config settings to make sure they're picked up later + LaravelConfigHelper::updateChannelsWhenNotKnown(['channels-when-not-known']); + LaravelConfigHelper::updateLevelWhenNotKnown(Settings::REPORTING_LEVEL_EMERGENCY); + + // pretend Control has called some code + $exception = SimulateControlPackage::pushControlCallMetaAndGenerateException(1); + + + + // build the Context object + $context = ContextAPI::buildContextFromException($exception, false, 1); + + + + // check the Context object… + + // check that the exception was used + self::assertSame($exception, $context->getException()); + self::assertSame(count($exception->getTrace()) + 1, $context->getStackTrace()->count()); + + // check the channels that were resolved + self::assertSame(['channels-when-not-known'], $context->getChannels()); + + // check the level that was resolved + self::assertSame(Settings::REPORTING_LEVEL_EMERGENCY, $context->getLevel()); + + // check the report and rethrow settings + self::assertSame(true, $context->getReport()); + self::assertSame(false, $context->getRethrow()); + + // check that the $catcherObjectId was used + // (incidentally checks that the CallMeta "catches" the exception, but that's tested in ContextUnitTest) + $callMeta = $context->getCallStack()->getMeta(CallMeta::class)[0] ?? null; + self::assertInstanceOf(CallMeta::class, $callMeta); + self::assertTrue($callMeta->wasCaughtHere()); + } +} diff --git a/tests/Integration/Support/Context/CallStack/CallStackAndFrameGenerationIntegrationTest.php b/tests/Integration/Support/Context/CallStack/CallStackAndFrameGenerationIntegrationTest.php new file mode 100644 index 0000000..33c937b --- /dev/null +++ b/tests/Integration/Support/Context/CallStack/CallStackAndFrameGenerationIntegrationTest.php @@ -0,0 +1,628 @@ + '111']); + Clarity::context('TWO'); + Clarity::context(['222' => '222']); + + // simulate Control running and catching an exception + $catcherObjectId = 1; + $e = SimulateControlPackage::pushControlCallMetaAndGenerateException($catcherObjectId); + $context = SimulateControlPackage::buildContext($catcherObjectId, $e); + ContextAPI::rememberExceptionContext($e, $context); + + $context = Clarity::getExceptionContext($e); + $callStack = $context->getCallStack(); + + + + // retrieve all meta + self::assertCount(8, $callStack->getMeta()); + + self::assertCount(1, $callStack->getMeta(ExceptionThrownMeta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionCaughtMeta::class)); + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + self::assertCount(1, $callStack->getMeta(CallMeta::class)); + self::assertCount(4, $callStack->getMeta(ContextMeta::class)); + + self::assertCount(5, $callStack->getMeta(LastApplicationFrameMeta::class, ContextMeta::class)); + self::assertCount(5, $callStack->getMeta([LastApplicationFrameMeta::class, ContextMeta::class])); + self::assertCount(5, $callStack->getMeta(LastApplicationFrameMeta::class, [ContextMeta::class])); + self::assertCount(5, $callStack->getMeta([LastApplicationFrameMeta::class], ContextMeta::class)); + self::assertCount(5, $callStack->getMeta([LastApplicationFrameMeta::class], [ContextMeta::class])); + + // check that the ContextMetas don't get doubled up + self::assertCount(8, $callStack->getMeta(LastApplicationFrameMeta::class, Meta::class)); + + /** @phpstan-ignore-next-line */ + self::assertCount(0, $callStack->getMeta(NonExistantClass::class)); + } + + + + /** + * Test that a CallStack is generated with the desired Frames and Meta objects, based on a thrown exception. + * + * @test + * + * @return void + */ + public static function test_callstack_frames_and_meta_objects_when_built_for_a_thrown_exception(): void + { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + + // simulate Control running and catching an exception + $catcherObjectId = 1; + $e = SimulateControlPackage::pushControlCallMetaAndGenerateException($catcherObjectId); + $context = SimulateControlPackage::buildContext($catcherObjectId, $e); + ContextAPI::rememberExceptionContext($e, $context); + + + // then retrieve it using Clarity + $context = Clarity::getExceptionContext($e); + $callStack = $context->getCallStack(); + + // test that the callstack SeekableIterator object has been rewound + self::assertSame($callStack[0], $callStack->current()); + + // FRAMES + self::assertInstanceOf(Frame::class, $callStack->getExceptionThrownFrame()); + self::assertNotNull($callStack->getExceptionThrownFrameIndex()); + + self::assertInstanceOf(Frame::class, $callStack->getExceptionCaughtFrame()); + self::assertNotNull($callStack->getExceptionCaughtFrameIndex()); + + self::assertInstanceOf(Frame::class, $callStack->getLastApplicationFrame()); + self::assertNotNull($callStack->getLastApplicationFrameIndex()); + + // META OBJECTS + self::assertCount(6, $callStack->getMeta(Meta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionThrownMeta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionCaughtMeta::class)); + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + self::assertCount(1, $callStack->getMeta(CallMeta::class)); + self::assertCount(2, $callStack->getMeta(ContextMeta::class)); + + /** @var ContextMeta[] $contextMetas */ + $contextMetas = $callStack->getMeta(ContextMeta::class); + self::assertCount(2, $contextMetas); + + self::assertSame(__FILE__, $contextMetas[0]->getFile()); + self::assertSame('hello', $contextMetas[0]->getContext()); + + self::assertSame(__FILE__, $contextMetas[1]->getFile()); + self::assertSame(['a' => 'b'], $contextMetas[1]->getContext()); + + /** @var CallMeta[] $callMetas */ + $callMetas = $callStack->getMeta(CallMeta::class); + self::assertCount(1, $callMetas); + self::assertSame(SimulateControlPackage::getClassFile(), $callMetas[0]->getFile()); + + /** @var ExceptionThrownMeta[] $exceptionThrownMetas */ + $exceptionThrownMetas = $callStack->getMeta(ExceptionThrownMeta::class); + self::assertCount(1, $exceptionThrownMetas); + self::assertSame(SimulateControlPackage::getClassFile(), $exceptionThrownMetas[0]->getFile()); + + /** @var ExceptionCaughtMeta[] $exceptionCaughtMetas */ + $exceptionCaughtMetas = $callStack->getMeta(ExceptionCaughtMeta::class); + self::assertCount(1, $exceptionCaughtMetas); + self::assertSame(SimulateControlPackage::getClassFile(), $exceptionCaughtMetas[0]->getFile()); + + /** @var LastApplicationFrameMeta[] $lastApplicationFrameMetas */ + $lastApplicationFrameMetas = $callStack->getMeta(LastApplicationFrameMeta::class); + self::assertCount(1, $lastApplicationFrameMetas); + self::assertSame(SimulateControlPackage::getClassFile(), $lastApplicationFrameMetas[0]->getFile()); + } + + /** + * Test that a CallStack is generated with the desired Frames and Meta objects, based on a passed exception. + * + * @test + * + * @return void + */ + public static function test_callstack_frames_and_meta_objects_when_built_from_passed_exception(): void + { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + + // build a fresh context directly + $context = Clarity::getExceptionContext(new Exception()); + $callStack = $context->getCallStack(); + + // FRAMES + self::assertInstanceOf(Frame::class, $callStack->getExceptionThrownFrame()); + self::assertNotNull($callStack->getExceptionThrownFrameIndex()); + + self::assertNull($callStack->getExceptionCaughtFrame()); + self::assertNull($callStack->getExceptionCaughtFrameIndex()); + + self::assertInstanceOf(Frame::class, $callStack->getLastApplicationFrame()); + self::assertNotNull($callStack->getLastApplicationFrameIndex()); + + // META OBJECTS + self::assertCount(4, $callStack->getMeta(Meta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionThrownMeta::class)); + self::assertCount(0, $callStack->getMeta(ExceptionCaughtMeta::class)); + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + self::assertCount(0, $callStack->getMeta(CallMeta::class)); + self::assertCount(2, $callStack->getMeta(ContextMeta::class)); + } + + /** + * Test that a CallStack is generated with the desired Frames and Meta objects, when not based on an exception. + * + * @test + * + * @return void + */ + public static function test_callstack_frames_and_meta_objects_when_not_built_from_an_exception(): void + { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + + $context = Clarity::buildContextHere(); + $callStack = $context->getCallStack(); + + // FRAMES + self::assertNull($callStack->getExceptionThrownFrame()); + self::assertNull($callStack->getExceptionThrownFrameIndex()); + + self::assertNull($callStack->getExceptionCaughtFrame()); + self::assertNull($callStack->getExceptionCaughtFrameIndex()); + + self::assertInstanceOf(Frame::class, $callStack->getLastApplicationFrame()); + self::assertNotNull($callStack->getLastApplicationFrameIndex()); + + // META OBJECTS + self::assertCount(3, $callStack->getMeta(Meta::class)); + self::assertCount(0, $callStack->getMeta(ExceptionThrownMeta::class)); + self::assertCount(0, $callStack->getMeta(ExceptionCaughtMeta::class)); + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + self::assertCount(0, $callStack->getMeta(CallMeta::class)); + self::assertCount(2, $callStack->getMeta(ContextMeta::class)); + } + + + + /** + * Test what happens when the project-root can't be resolved for some reason. + * + * @test + * + * @return void + */ + public static function test_what_happens_when_the_project_root_cant_be_resolved(): void + { + $e = null; + try { + // generate an exception to use below + /** @phpstan-ignore-next-line */ + app()->make(NonExistantClass::class); + } catch (Throwable $e) { + } + /** @var Exception $e */ + + $context = new Context( + $e, + null, + new MetaCallStack(), + -1, + [], + '', // <<< no project root dir + ['stack'], + Settings::REPORTING_LEVEL_DEBUG, + false, + false, + null, + ); + $callStack = $context->getCallStack(); + + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + + $containerFile = '/vendor/laravel/framework/src/Illuminate/Container/Container.php'; + $containerFile = str_replace('/', DIRECTORY_SEPARATOR, $containerFile); + + // the vendor directory can't be determined properly, so it picks the last frame as the application frame + $frame = $callStack->getLastApplicationFrame(); + self::assertInstanceOf(Frame::class, $frame); + self::assertStringEndsWith($containerFile, $frame->getFile()); + self::assertStringEndsWith($containerFile, $frame->getProjectFile()); + } + + + + /** + * Test that the Meta objects match the frames. + * + * @test + * + * @return void + */ + public static function test_that_meta_matches_the_frames(): void + { + // simulate Control running and catching an exception + $catcherObjectId = 1; + $e = SimulateControlPackage::pushControlCallMetaAndGenerateException($catcherObjectId); + $context = SimulateControlPackage::buildContext($catcherObjectId, $e); + + foreach ($context->getCallStack() as $frame) { + + $metaCount = count($frame->getMeta(LastApplicationFrameMeta::class)); + $frame->isLastApplicationFrame() + ? self::assertSame(1, $metaCount) + : self::assertSame(0, $metaCount); + + $metaCount = count($frame->getMeta(ExceptionThrownMeta::class)); + $frame->exceptionWasThrownHere() + ? self::assertSame(1, $metaCount) + : self::assertSame(0, $metaCount); + + $metaCount = count($frame->getMeta(ExceptionCaughtMeta::class)); + $frame->exceptionWasCaughtHere() + ? self::assertSame(1, $metaCount) + : self::assertSame(0, $metaCount); + } + } + + + + /** + * Test the generation and retrieval of the ExceptionThrownMeta object from the callstack. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_exception_thrown_meta(): void + { + $callback = function (Context $context, Exception $e) { + + $exceptionTrace = array_reverse(self::getExceptionCallStackArray($e)); + $frame = $exceptionTrace[0]; + + $callStack = $context->getCallStack(); + + $frameIndex = count($callStack) - 1; + + /** @var Frame $currentFrame */ + $currentFrame = $callStack[$frameIndex]; + + self::assertSame($currentFrame, $callStack->getExceptionThrownFrame()); + self::assertTrue(!is_null($callStack->getExceptionThrownFrameIndex())); + + self::assertSame($currentFrame, $context->getStackTrace()->getExceptionThrownFrame()); + self::assertTrue(!is_null($context->getStackTrace()->getExceptionThrownFrameIndex())); + + $metaObjects1 = $currentFrame->getMeta(ExceptionThrownMeta::class); + $metaObjects2 = $callStack->getMeta(ExceptionThrownMeta::class); + + self::assertCount(1, $metaObjects1); + self::assertSame($metaObjects1, $metaObjects2); + + /** @var ExceptionThrownMeta $meta1 */ + $meta1 = $metaObjects1[0]; + + self::assertInstanceOf(ExceptionThrownMeta::class, $meta1); + + self::assertSame($frame['file'], $meta1->getFile()); + self::assertSame($frame['line'], $meta1->getLine()); + + $context->getException() instanceof BindingResolutionException + ? null // the line inside the vendor directory may change + : self::assertSame(__LINE__ + 16, $meta1->getLine()); // last frame is an APPLICATION file + }; + + // test when the last frame is a VENDOR frame + try { + // generate an exception to use below + /** @phpstan-ignore-next-line */ + app()->make(NonExistantClass::class); + } catch (BindingResolutionException $e) { + $context = ContextAPI::buildContextFromException($e); + $callback($context, $e); + } + + // test when the last frame is an APPLICATION frame + try { + // generate an exception to use below + throw new Exception(); + } catch (Exception $e) { + $context = ContextAPI::buildContextFromException($e); + $callback($context, $e); + } + } + + + + /** + * Test the generation and retrieval of the ExceptionCaughtMeta object from the callstack. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_exception_caught_meta(): void + { + $callback = function (Context $context) { + + $callStack = $context->getCallStack(); + $trace = $context->getStackTrace(); + + /** @var Frame $currentFrame */ + $currentFrame = null; + foreach ($context->getCallStack() as $tempFrame) { + if ($tempFrame->exceptionWasCaughtHere()) { + $currentFrame = $tempFrame; + break; + } + } + + self::assertNotNull($currentFrame); + + self::assertSame($currentFrame, $callStack->getExceptionCaughtFrame()); + self::assertTrue(!is_null($callStack->getExceptionCaughtFrameIndex())); + + self::assertSame($currentFrame, $trace->getExceptionCaughtFrame()); + self::assertTrue(!is_null($trace->getExceptionCaughtFrameIndex())); + + /** @var Frame $currentFrame */ + $metaObjects1 = $currentFrame->getMeta(ExceptionCaughtMeta::class); + $metaObjects2 = $callStack->getMeta(ExceptionCaughtMeta::class); + + self::assertCount(1, $metaObjects1); + self::assertSame($metaObjects1, $metaObjects2); + + /** @var ExceptionCaughtMeta $meta1 */ + $meta1 = $metaObjects1[0]; + + self::assertInstanceOf(ExceptionCaughtMeta::class, $meta1); + }; + + // simulate Control running and catching an exception + $catcherObjectId = 1; + SimulateControlPackage::pushControlCallMetaAndGenerateException($catcherObjectId, [], 1); + + // test when the last frame is a VENDOR frame + try { + // generate an exception to use below + /** @phpstan-ignore-next-line */ + app()->make(NonExistantClass::class); + } catch (BindingResolutionException $e) { + $context = ContextAPI::buildContextFromException($e, false, $catcherObjectId); + $callback($context); + } + + // test when the last frame is an APPLICATION frame + try { + // generate an exception to use below + throw new Exception(); + } catch (Exception $e) { + $context = ContextAPI::buildContextFromException($e, false, $catcherObjectId); + $callback($context); + } + } + + + + /** + * Test the generation and retrieval of the LastApplicationFrameMeta object from the callstack. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_last_application_frame_meta(): void + { + $callback = function (Context $context, Exception $e) { + + $path = 'tests/Integration/Support/Context/CallStack/CallStackAndFrameGenerationIntegrationTest.php'; + $path = str_replace('/', DIRECTORY_SEPARATOR, $path); + + // find the last application (i.e. non-vendor) frame + $frame = null; + $frameIndex = 0; + $count = 0; + $exceptionTrace = array_reverse(self::getExceptionCallStackArray($e)); + foreach ($exceptionTrace as $tempFrame) { + + $file = is_string($tempFrame['file'] ?? null) + ? $tempFrame['file'] + : ''; + + if (mb_substr($file, - mb_strlen($path)) == $path) { + $frame = $tempFrame; + $frameIndex = (count($exceptionTrace) - 1) - $count; + + break; + } + $count++; + } + self::assertNotNull($frame); + + + + $callStack = $context->getCallStack(); + /** @var Frame $currentFrame */ + $currentFrame = $callStack[$frameIndex]; + + self::assertSame($currentFrame, $callStack->getLastApplicationFrame()); + self::assertTrue(!is_null($callStack->getLastApplicationFrameIndex())); + + self::assertSame($currentFrame, $context->getStackTrace()->getLastApplicationFrame()); + self::assertTrue(!is_null($context->getStackTrace()->getLastApplicationFrameIndex())); + + $metaObjects1 = $currentFrame->getMeta(LastApplicationFrameMeta::class); + $metaObjects2 = $callStack->getMeta(LastApplicationFrameMeta::class); + + self::assertCount(1, $metaObjects1); + self::assertSame($metaObjects1, $metaObjects2); + + /** @var LastApplicationFrameMeta $meta1 */ + $meta1 = $metaObjects1[0]; + + self::assertInstanceOf(LastApplicationFrameMeta::class, $meta1); + + self::assertSame($frame['file'], $meta1->getFile()); + self::assertSame($frame['line'], $meta1->getLine()); + + self::assertSame(__FILE__, $meta1->getfile()); + + $context->getException() instanceof BindingResolutionException + ? self::assertSame(__LINE__ + 8, $meta1->getLine()) // last frame is a VENDOR file + : self::assertSame(__LINE__ + 16, $meta1->getLine()); // last frame is an APPLICATION file + }; + + // test when the last frame is a VENDOR frame + try { + // generate an exception to use below + /** @phpstan-ignore-next-line */ + app()->make(NonExistantClass::class); + } catch (BindingResolutionException $e) { + $context = ContextAPI::buildContextFromException($e); + $callback($context, $e); + } + + // test when the last frame is an APPLICATION frame + try { + // generate an exception to use below + throw new Exception(); + } catch (Exception $e) { + $context = ContextAPI::buildContextFromException($e); + $callback($context, $e); + } + } + + + + /** + * Test that the file and line numbers are shifted by 1 frame when building the callstack. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_that_the_callstack_file_and_line_numbers_are_shifted_by_1(): void + { + $closure = function () { + + $context = Clarity::buildContextHere(); + + // find the two frames from within this file + $frames = []; + /** @var Frame $frame */ + foreach ($context->getCallStack() as $frame) { + if ($frame->getFile() == __FILE__) { + $frames[] = $frame; + } + } + + $frame = $frames[0]; + self::assertSame( + 'test_that_the_callstack_file_and_line_numbers_are_shifted_by_1', + $frame->getFunction() + ); + self::assertSame(__FILE__, $frame->getFile()); + self::assertSame(__LINE__ + 11, $frame->getLine()); + + $frame = $frames[1]; + self::assertSame( + 'CodeDistortion\ClarityContext\Tests\Integration\Support\Context\CallStack\{closure}', + $frame->getFunction() + ); + self::assertSame(__FILE__, $frame->getFile()); + self::assertSame(__LINE__ - 25, $frame->getLine()); + }; + + $closure(); + } + + + + /** + * Test the last frame is marked as the last frame. + * + * @test + * + * @return void + */ + public static function test_that_the_last_frame_is_marked_so(): void + { + // simulate Control running and catching an exception + $catcherObjectId = 1; + $e = SimulateControlPackage::pushControlCallMetaAndGenerateException($catcherObjectId); + $context = SimulateControlPackage::buildContext($catcherObjectId, $e); + + $count = 0; + foreach ($context->getStackTrace() as $frame) { + $count++ == 0 + ? self::assertTrue($frame->isLastFrame()) + : self::assertFalse($frame->isLastFrame()); + } + } + + + + + + /** + * Build the exception's callstack. Include the exception's location as a frame. + * + * @param Throwable $e The exception to use. + * @return array + */ + private static function getExceptionCallStackArray(Throwable $e): array + { + $exceptionCallStack = array_reverse($e->getTrace()); + + // add the exception's location as the last frame + $exceptionCallStack[] = [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]; + + return $exceptionCallStack; + } +} diff --git a/tests/Integration/Support/MetaCallStackPruning2IntegrationTest.php b/tests/Integration/Support/MetaCallStackPruning2IntegrationTest.php new file mode 100644 index 0000000..09d7211 --- /dev/null +++ b/tests/Integration/Support/MetaCallStackPruning2IntegrationTest.php @@ -0,0 +1,126 @@ +addContextAndCheckMeta($context); // add some context (or not) + self::assertSame($context, self::getContextValue($callStack)); + } + } + + + + /** + * Test that meta-data gets pruned properly, when a method is called on a class static-method. + * + * NOTE: Because the calls to the static method within the loop occur on the same line, the frames look the same to + * Clarity. Unfortunately it can't tell that they're *different* calls to the same method, so it won't prune the + * "something" context from the first loop iteration. + * + * This test just confirms this bleeding :[, as it is "known" behaviour. + * + * @test + * + * @return void + */ + public static function test_frame_pruning_when_calling_a_class_static_method(): void + { + for ($count = 0; $count <= 1; $count++) { + + $context = $count == 0 + ? 'something' + : null; + + $callStack = SomeOtherClass::addContextAndCheckMetaStatic($context); // add some context (or not) + self::assertSame('something', self::getContextValue($callStack)); // is always "something" + } + } + + + + /** + * Test that meta-data gets pruned properly, when a closure is called. + * + * NOTE: Because the calls to the closure within the loop occur on the same line, the frames look the same to + * Clarity. Unfortunately it can't tell that they're *different* calls to the closure, so it won't prune the + * "something" context from the first loop iteration. + * + * This test just confirms this bleeding :[, as it is "known" behaviour. + * + * @test + * + * @return void + */ + public static function test_frame_pruning_when_calling_a_closure(): void + { + $callback = function (?string $context) { + + if ($context) { + Clarity::context($context); + } + + return Clarity::buildContextHere()->getCallStack(); + }; + + for ($count = 0; $count <= 1; $count++) { + + $context = $count == 0 + ? 'something' + : null; + + $callStack = $callback($context); // add some context (or not) + self::assertSame('something', self::getContextValue($callStack)); // is always "something" + } + } + + + + /** + * Get the context string or array from the last frame in a CallStack. + * + * @param CallStack $callStack The CallStack to check. + * @return string|mixed[]|null + */ + private static function getContextValue(CallStack $callStack): string|array|null + { + $index = count($callStack) - 1; + /** @var Frame $frame */ + $frame = $callStack[$index]; + /** @var ContextMeta[] $contextMetas */ + $contextMetas = $frame->getMeta(ContextMeta::class); + return count($contextMetas) + ? $contextMetas[0]->getContext() + : null; + } +} diff --git a/tests/LaravelTestCase.php b/tests/LaravelTestCase.php new file mode 100644 index 0000000..381e7cf --- /dev/null +++ b/tests/LaravelTestCase.php @@ -0,0 +1,27 @@ + + */ + // phpcs:ignore + protected function getPackageProviders($app) + { + return [ + ServiceProvider::class + ]; + } +} diff --git a/tests/PHPUnitTestCase.php b/tests/PHPUnitTestCase.php new file mode 100644 index 0000000..1a5959c --- /dev/null +++ b/tests/PHPUnitTestCase.php @@ -0,0 +1,12 @@ +updateConfig([InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.enabled' => true]); + } + + /** + * Disable Clarity via the Laravel config. + * + * @return void + */ + public static function disableClarity(): void + { + Framework::config()->updateConfig([InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.enabled' => false]); + } + + + + /** + * Set the "report" setting. + * + * @param boolean|null $report The report setting to use. + * @return void + */ + public static function updateReportSetting(?bool $report): void + { + Framework::config()->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => $report]); + } + + + + /** + * Set the "channels when known" setting. + * + * @param string[] $channels The channels to set. + * @return void + */ + public static function updateChannelsWhenKnown(array $channels): void + { + Framework::config()->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => $channels] + ); + } + + /** + * Set the "channels when not known" setting. + * + * @param string[] $channels The channels to set. + * @return void + */ + public static function updateChannelsWhenNotKnown(array $channels): void + { + Framework::config()->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => $channels] + ); + } + + + + /** + * Set the "level when known" setting. + * + * @param string $level The level to set. + * @return void + */ + public static function updateLevelWhenKnown(string $level): void + { + Framework::config()->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_known' => $level] + ); + } + + /** + * Set the "level when not known" setting. + * + * @param string $level The level to set. + * @return void + */ + public static function updateLevelWhenNotKnown(string $level): void + { + Framework::config()->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known' => $level] + ); + } +} diff --git a/tests/TestSupport/MethodCall.php b/tests/TestSupport/MethodCall.php new file mode 100644 index 0000000..dd18e91 --- /dev/null +++ b/tests/TestSupport/MethodCall.php @@ -0,0 +1,67 @@ +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/TestSupport/MethodCalls.php b/tests/TestSupport/MethodCalls.php new file mode 100644 index 0000000..a69bc90 --- /dev/null +++ b/tests/TestSupport/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/TestSupport/PHPStackTraceHelper.php b/tests/TestSupport/PHPStackTraceHelper.php new file mode 100644 index 0000000..6460783 --- /dev/null +++ b/tests/TestSupport/PHPStackTraceHelper.php @@ -0,0 +1,31 @@ +> + */ + public static function buildPHPStackTraceHere(): array + { + return debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS); + } + + /** + * Work out what the current stack trace frame index is. + * + * @return integer + */ + public static function getCurrentFrameIndex(): int + { + $phpStackTrace = PHPStackTraceHelper::buildPHPStackTraceHere(); + array_pop($phpStackTrace); + return max(array_keys($phpStackTrace)); + } +} diff --git a/tests/TestSupport/SimulateControlPackage.php b/tests/TestSupport/SimulateControlPackage.php new file mode 100644 index 0000000..9c5d84a --- /dev/null +++ b/tests/TestSupport/SimulateControlPackage.php @@ -0,0 +1,119 @@ + $known, + ]; + + MetaCallStackAPI::pushMetaData( + InternalSettings::META_DATA_TYPE__CONTROL_CALL, + $catcherObjectId, + $metaData, + $framesBack + 1, + [InternalSettings::META_DATA_TYPE__CONTROL_CALL], + ); + } + + /** + * Push "call" meta-data (with "known" values) to the global MetaCallStack, and generate an exception in the same + * frame. + * + * @param integer $catcherObjectId The id of the caller object. + * @param string[] $known The "known" issues to add. + * @param integer $framesBack The number of frames to go back. + * @return Throwable + */ + public static function pushControlCallMetaAndGenerateException( + int $catcherObjectId, + array $known = [], + int $framesBack = 0, + ): Throwable { + + self::pushControlCallMetaHere($catcherObjectId, $known, $framesBack); + + return new Exception(); + } + + + + /** + * Build a context object more easily. + * + * @param integer $catcherObjectId The id of the object that caught the exception. + * @param Throwable $e The exception to use. + * @param string[] $channels The channels to use. + * @param string $level The level to use. + * @param boolean $report Should the exception be reported?. + * @param boolean $rethrow Should the exception be rethrown?. + * @param mixed|null $default The default value to use. + * @return Context + */ + public static function buildContext( + int $catcherObjectId, + Throwable $e, + array $channels = ['stack'], + string $level = Settings::REPORTING_LEVEL_ERROR, + bool $report = true, + bool $rethrow = false, + mixed $default = null, + ): Context { + + $context = new Context( + $e, + null, + Support::getGlobalMetaCallStack(), + $catcherObjectId, + [], + Framework::config()->getProjectRootDir(), + $channels, + $level, + $report, + $rethrow, + $default, + ); + + ContextAPI::rememberExceptionContext($e, $context); + + return $context; + } + + /** + * Get this class's file path. + * + * @return string + */ + public static function getClassFile(): string + { + return __FILE__; + } +} diff --git a/tests/TestSupport/SomeOtherClass.php b/tests/TestSupport/SomeOtherClass.php new file mode 100644 index 0000000..862cdd0 --- /dev/null +++ b/tests/TestSupport/SomeOtherClass.php @@ -0,0 +1,55 @@ +getCallStack(); + } + + /** + * Add context to Clarity, and check the meta that's recorded. + * + * @param string|null $context The context to store (will skip when empty). + * @return CallStack + */ + public static function addContextAndCheckMetaStatic(?string $context): CallStack + { + if ($context) { + Clarity::context($context); + } + + return Clarity::buildContextHere()->getCallStack(); + } + + /** + * Generate an exception and return it. + * + * @return Exception + */ + public static function generateException(): Exception + { + return new Exception(); + } +} diff --git a/tests/TestSupport/ValueObject.php b/tests/TestSupport/ValueObject.php new file mode 100644 index 0000000..608c903 --- /dev/null +++ b/tests/TestSupport/ValueObject.php @@ -0,0 +1,28 @@ +value; + } +} diff --git a/tests/Unit/API/ContextAPIUnitTest.php b/tests/Unit/API/ContextAPIUnitTest.php new file mode 100644 index 0000000..438c367 --- /dev/null +++ b/tests/Unit/API/ContextAPIUnitTest.php @@ -0,0 +1,223 @@ +getCallStack()->count()); + self::assertSame(['channel-when-not-known'], $context->getChannels()); + self::assertNull($context->getLevel()); + } + + + + + + /** + * Test that the buildContextHere() builds a Context object as expected. + * + * @test + * @dataProvider phpStackTraceDataProvider + * + * @param integer $framesBack The number of frames to go back. + * @param boolean $expectException Whether an exception is expected or not. + * @return void + */ + public static function test_build_context_here_method_builds_based_on_frames_back( + int $framesBack, + bool $expectException + ): void { + + $caughtException = false; + try { + + $context = ContextAPI::buildContextHere($framesBack); + + $phpStackTrace = PHPStackTraceHelper::buildPHPStackTraceHere(); + self::assertSame(count($phpStackTrace) - $framesBack, $context->getCallStack()->count()); + + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertSame($expectException, $caughtException); + } + + /** + * DataProvider for test_build_context_method_for_php_stack_trace(). + * + * @return array> + */ + public static function phpStackTraceDataProvider(): array + { + return [ + ['framesBack' => 100000, 'expectException' => true], + ['framesBack' => 1, 'expectException' => false], + ['framesBack' => 0, 'expectException' => false], + ['framesBack' => -1, 'expectException' => true], + ]; + } + + + + + + /** + * Test that the buildContextFromException() builds a Context object as expected. + * + * @test + * @dataProvider buildContextFromExceptionDataProvider + * + * @param boolean|null $report Whether to report the exception or not. + * @param boolean $isKnown Whether to say the exception was known or not. + * @param boolean $catch Whether to pretend to catch the exception or not. + * @return void + */ + public static function test_build_context_from_exception_method(?bool $report, bool $isKnown, bool $catch): void + { + LaravelConfigHelper::updateReportSetting($report); + LaravelConfigHelper::updateChannelsWhenKnown(['channel-when-known']); + LaravelConfigHelper::updateChannelsWhenNotKnown(['channel-when-not-known']); + LaravelConfigHelper::updateLevelWhenKnown(Settings::REPORTING_LEVEL_DEBUG); + LaravelConfigHelper::updateLevelWhenNotKnown(Settings::REPORTING_LEVEL_WARNING); + + + + $catcherObjectId = 1; + SimulateControlPackage::pushControlCallMetaHere($catcherObjectId, [], 1); + + $phpStackTrace = PHPStackTraceHelper::buildPHPStackTraceHere(); + + + + $exception = new Exception(); + $context = ContextAPI::buildContextFromException($exception, $isKnown, $catch ? $catcherObjectId : null); + + + + + self::assertSame($exception, $context->getException()); + self::assertSame(count($phpStackTrace), $context->getCallStack()->count()); + + + + self::assertSame($report ?? true, $context->getReport()); + + // $isKnown doesn't affect the $context->hasKnown() setting, instead it affects the channels and level chosen + if ($isKnown) { + self::assertSame(['channel-when-known'], $context->getChannels()); + self::assertSame(Settings::REPORTING_LEVEL_DEBUG, $context->getLevel()); + } else { + self::assertSame(['channel-when-not-known'], $context->getChannels()); + self::assertSame(Settings::REPORTING_LEVEL_WARNING, $context->getLevel()); + } + + self::assertCount($catch ? 1 : 0, $context->getCallStack()->getMeta(ExceptionCaughtMeta::class)); + + + + // test that the exception's context has been remembered + self::assertSame($context, ContextAPI::getRememberedExceptionContext($exception)); + } + + /** + * DataProvider for test_build_context_method_from_an_exception(). + * + * @return array> + */ + public static function buildContextFromExceptionDataProvider(): array + { + return [ + ['report' => false, 'isKnown' => false, 'catch' => false], + ['report' => false, 'isKnown' => false, 'catch' => true], + ['report' => false, 'isKnown' => true, 'catch' => false], + ['report' => false, 'isKnown' => true, 'catch' => true], + ['report' => true, 'isKnown' => false, 'catch' => false], + ['report' => true, 'isKnown' => false, 'catch' => true], + ['report' => true, 'isKnown' => true, 'catch' => false], + ['report' => true, 'isKnown' => true, 'catch' => true], + ['report' => null, 'isKnown' => false, 'catch' => false], + ['report' => null, 'isKnown' => false, 'catch' => true], + ['report' => null, 'isKnown' => true, 'catch' => false], + ['report' => null, 'isKnown' => true, 'catch' => true], + ]; + } + + + + + + /** + * Test that an exception's context object can be remembered, retrieved, and forgotten. + * + * @test + * + * @return void + */ + public static function test_the_context_remember_retrieve_and_forget_methods(): void + { + self::assertNull(ContextAPI::getLatestExceptionContext()); + + $exception1 = new Exception(); + $exception2 = new Exception(); + + self::assertNull(ContextAPI::getLatestExceptionContext()); + self::assertNull(ContextAPI::getRememberedExceptionContext($exception1)); + self::assertNull(ContextAPI::getRememberedExceptionContext($exception2)); + + $context1 = ContextAPI::buildContextFromException($exception1); + + self::assertSame($context1, ContextAPI::getLatestExceptionContext()); + self::assertSame($context1, ContextAPI::getRememberedExceptionContext($exception1)); + self::assertNull(ContextAPI::getRememberedExceptionContext($exception2)); + + $context2 = ContextAPI::buildContextFromException($exception2); + + self::assertSame($context2, ContextAPI::getLatestExceptionContext()); + self::assertSame($context1, ContextAPI::getRememberedExceptionContext($exception1)); + self::assertSame($context2, ContextAPI::getRememberedExceptionContext($exception2)); + + ContextAPI::forgetExceptionContext($exception2); + + self::assertSame($context1, ContextAPI::getLatestExceptionContext()); + self::assertSame($context1, ContextAPI::getRememberedExceptionContext($exception1)); + self::assertNull(ContextAPI::getRememberedExceptionContext($exception2)); + + ContextAPI::forgetExceptionContext($exception1); + + self::assertNull(ContextAPI::getLatestExceptionContext()); + self::assertNull(ContextAPI::getRememberedExceptionContext($exception1)); + self::assertNull(ContextAPI::getRememberedExceptionContext($exception2)); + } +} diff --git a/tests/Unit/API/DataAPIUnitTest.php b/tests/Unit/API/DataAPIUnitTest.php new file mode 100644 index 0000000..b4b022d --- /dev/null +++ b/tests/Unit/API/DataAPIUnitTest.php @@ -0,0 +1,73 @@ +getStackMetaData(); + self::assertCount(0, $metaData); + } + + /** + * Test the MetaCallStackAPI pushMetaData method. + * + * @test + * + * @return void + */ + public static function test_push_meta_data_method(): void + { + // add some meta-data + MetaCallStackAPI::pushMetaData('type-a', null, 'hello 1 a', 1); + MetaCallStackAPI::pushMetaData('type-b', null, 'hello 2 b', 1); + MetaCallStackAPI::pushMetaData('type-a', 123, 'hello 3 a'); // $framesBack implicit + MetaCallStackAPI::pushMetaData('type-a', 'some-id', 'hello 4 a', 0); // $framesBack explicit + MetaCallStackAPI::pushMetaData('type-b', 123, 'hello 5 b'); + MetaCallStackAPI::pushMetaData('type-b', 'some-id', 'hello 6 b'); + + // check which meta-data was stored + $metaCallStack = Support::getGlobalMetaCallStack(); + $metaData = $metaCallStack->getStackMetaData(); + + $frameCount = count(debug_backtrace()); + self::assertSame(range($frameCount - 1, $frameCount), array_keys($metaData)); + + self::assertSame('type-a', $metaData[$frameCount - 1][0]['type'] ?? null); + self::assertSame(null, $metaData[$frameCount - 1][0]['identifier'] ?? null); + self::assertSame('hello 1 a', $metaData[$frameCount - 1][0]['value'] ?? null); + + self::assertSame('type-b', $metaData[$frameCount - 1][1]['type'] ?? null); + self::assertSame(null, $metaData[$frameCount - 1][1]['identifier'] ?? null); + self::assertSame('hello 2 b', $metaData[$frameCount - 1][1]['value'] ?? null); + + self::assertSame('type-a', $metaData[$frameCount][0]['type'] ?? null); + self::assertSame(123, $metaData[$frameCount][0]['identifier'] ?? null); + self::assertSame('hello 3 a', $metaData[$frameCount][0]['value'] ?? null); + + self::assertSame('type-a', $metaData[$frameCount][1]['type'] ?? null); + self::assertSame('some-id', $metaData[$frameCount][1]['identifier'] ?? null); + self::assertSame('hello 4 a', $metaData[$frameCount][1]['value'] ?? null); + + self::assertSame('type-b', $metaData[$frameCount][2]['type'] ?? null); + self::assertSame(123, $metaData[$frameCount][2]['identifier'] ?? null); + self::assertSame('hello 5 b', $metaData[$frameCount][2]['value'] ?? null); + + self::assertSame('type-b', $metaData[$frameCount][3]['type'] ?? null); + self::assertSame('some-id', $metaData[$frameCount][3]['identifier'] ?? null); + self::assertSame('hello 6 b', $metaData[$frameCount][3]['value'] ?? null); + } + + /** + * Test the MetaCallStackAPI pushMetaData method when invalid $framesBack has been specified. + * + * @test + * + * @return void + */ + public static function test_push_meta_data_method_with_invalid_frames_back(): void + { + // test going back too many frames + $caughtException = false; + try { + // generate an exception + MetaCallStackAPI::pushMetaData('type-a', null, 'hello 1', -1); + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertTrue($caughtException); + } + + + + + + + /** + * Test the MetaCallStackAPI pushMultipleMetaData method when Clarity has been disabled. + * + * @test + * + * @return void + */ + public static function test_push_multiple_meta_data_method_when_clarity_is_disabled(): void + { + LaravelConfigHelper::disableClarity(); + + MetaCallStackAPI::pushMultipleMetaData('type-a', null, ['hello 1']); + + $metaCallStack = Support::getGlobalMetaCallStack(); + $metaData = $metaCallStack->getStackMetaData(); + self::assertCount(0, $metaData); + } + + /** + * Test the MetaCallStackAPI pushMultipleMetaData method. + * + * @test + * + * @return void + */ + public static function test_push_multiple_meta_data_method(): void + { + // add some meta-data + MetaCallStackAPI::pushMultipleMetaData('type-a', null, ['hello 1 a', 'hello 2 a'], 1); + MetaCallStackAPI::pushMultipleMetaData('type-a', 123, ['hello 3 a']); // $framesBack implicit + MetaCallStackAPI::pushMultipleMetaData('type-a', 'some-id', ['hello 4 a'], 0); // $framesBack explicit + MetaCallStackAPI::pushMultipleMetaData('type-b', 123, ['hello 5 b']); + MetaCallStackAPI::pushMultipleMetaData('type-b', 'some-id', ['hello 6 b']); + + // check which meta-data was stored + $metaCallStack = Support::getGlobalMetaCallStack(); + $metaData = $metaCallStack->getStackMetaData(); + + $frameCount = count(debug_backtrace()); + self::assertSame(range($frameCount - 1, $frameCount), array_keys($metaData)); + + self::assertSame('type-a', $metaData[$frameCount - 1][0]['type'] ?? null); + self::assertSame(null, $metaData[$frameCount - 1][0]['identifier'] ?? null); + self::assertSame('hello 1 a', $metaData[$frameCount - 1][0]['value'] ?? null); + + self::assertSame('type-a', $metaData[$frameCount - 1][1]['type'] ?? null); + self::assertSame(null, $metaData[$frameCount - 1][1]['identifier'] ?? null); + self::assertSame('hello 2 a', $metaData[$frameCount - 1][1]['value'] ?? null); + + self::assertSame('type-a', $metaData[$frameCount][0]['type'] ?? null); + self::assertSame(123, $metaData[$frameCount][0]['identifier'] ?? null); + self::assertSame('hello 3 a', $metaData[$frameCount][0]['value'] ?? null); + + self::assertSame('type-a', $metaData[$frameCount][1]['type'] ?? null); + self::assertSame('some-id', $metaData[$frameCount][1]['identifier'] ?? null); + self::assertSame('hello 4 a', $metaData[$frameCount][1]['value'] ?? null); + + self::assertSame('type-b', $metaData[$frameCount][2]['type'] ?? null); + self::assertSame(123, $metaData[$frameCount][2]['identifier'] ?? null); + self::assertSame('hello 5 b', $metaData[$frameCount][2]['value'] ?? null); + + self::assertSame('type-b', $metaData[$frameCount][3]['type'] ?? null); + self::assertSame('some-id', $metaData[$frameCount][3]['identifier'] ?? null); + self::assertSame('hello 6 b', $metaData[$frameCount][3]['value'] ?? null); + } + + /** + * Test the MetaCallStackAPI pushMultipleMetaData method when invalid $framesBack has been specified. + * + * @test + * + * @return void + */ + public static function test_push_multiple_meta_data_method_with_invalid_frames_back(): void + { + // test going back too many frames + $caughtException = false; + try { + // generate an exception + MetaCallStackAPI::pushMultipleMetaData('type-a', null, ['hello 1'], -1); + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertTrue($caughtException); + } + + + + + + /** + * Test the MetaCallStackAPI pushMetaData method when Clarity has been disabled. + * + * @test + * + * @return void + */ + public static function test_replace_meta_data_method_when_clarity_is_disabled(): void + { + // add some meta-data + try to replace, even though Clarity has been disabled + MetaCallStackAPI::pushMetaData('type-a', 123, 'hello 1 a'); + LaravelConfigHelper::disableClarity(); + MetaCallStackAPI::replaceMetaData('type-a', 123, 'new 1'); + + $metaCallStack = Support::getGlobalMetaCallStack(); + $metaData = $metaCallStack->getStackMetaData(); + + $frameCount = count(debug_backtrace()); + + self::assertSame('hello 1 a', $metaData[$frameCount][0]['value'] ?? null); + } + + /** + * Test the MetaCallStackAPI pushMetaData method. + * + * @test + * + * @return void + */ + public static function test_replace_meta_data_method(): void + { + $summariseMetaData = function () { + $metaCallStack = Support::getGlobalMetaCallStack(); + $metaData = $metaCallStack->getStackMetaData(); + + $return = []; + foreach (array_keys($metaData) as $frameCount) { + foreach ($metaData[$frameCount] as $index => $oneMetaData) { + /** @var string $value Just pretend. */ + $value = $oneMetaData['value']; + $return[] = "$frameCount $index $value"; + } + } + + return $return; + }; + + + + // add some meta-data + MetaCallStackAPI::pushMetaData('type-a', 123, 'hello 1 a', 2); + MetaCallStackAPI::pushMetaData('type-b', null, 'hello 2 b', 2); + MetaCallStackAPI::pushMetaData('type-a', 123, 'hello 3 a', 1); + MetaCallStackAPI::pushMetaData('type-b', 123, 'hello 4 b', 1); + MetaCallStackAPI::pushMetaData('type-a', '456', 'hello 5 a', 0); + MetaCallStackAPI::pushMetaData('type-a', 789, 'hello 6 a', 0); + + $frameCount = count(debug_backtrace()); + + + + // initial state + $a = [ + ($frameCount - 2) . " 0 hello 1 a", + ($frameCount - 2) . " 1 hello 2 b", + ($frameCount - 1) . " 0 hello 3 a", + ($frameCount - 1) . " 1 hello 4 b", + ($frameCount) . " 0 hello 5 a", + ($frameCount) . " 1 hello 6 a", + ]; + self::assertSame($a, $summariseMetaData()); + + + + // test type-a with identifier 123 + MetaCallStackAPI::replaceMetaData('type-a', 123, 'new 1'); + $a = [ + ($frameCount - 2) . " 0 new 1", + ($frameCount - 2) . " 1 hello 2 b", + ($frameCount - 1) . " 0 new 1", + ($frameCount - 1) . " 1 hello 4 b", + ($frameCount) . " 0 hello 5 a", + ($frameCount) . " 1 hello 6 a", + ]; + self::assertSame($a, $summariseMetaData()); + + + + // test type-b with identifier 123 + MetaCallStackAPI::replaceMetaData('type-b', 123, 'new 2'); + $a = [ + ($frameCount - 2) . " 0 new 1", + ($frameCount - 2) . " 1 hello 2 b", + ($frameCount - 1) . " 0 new 1", + ($frameCount - 1) . " 1 new 2", + ($frameCount) . " 0 hello 5 a", + ($frameCount) . " 1 hello 6 a", + ]; + self::assertSame($a, $summariseMetaData()); + + + + // test when the identifier doesn't exist + MetaCallStackAPI::replaceMetaData('type-b', 'doesnt-exist', 'new 3'); + $a = [ + ($frameCount - 2) . " 0 new 1", + ($frameCount - 2) . " 1 hello 2 b", + ($frameCount - 1) . " 0 new 1", + ($frameCount - 1) . " 1 new 2", + ($frameCount) . " 0 hello 5 a", + ($frameCount) . " 1 hello 6 a", + ]; + self::assertSame($a, $summariseMetaData()); + + + + // test type-a with identifier 456 (INTEGER identifier) (no change) (i.e. test identifier TYPE) + MetaCallStackAPI::replaceMetaData('type-a', 456, 'new 4'); + $a = [ + ($frameCount - 2) . " 0 new 1", + ($frameCount - 2) . " 1 hello 2 b", + ($frameCount - 1) . " 0 new 1", + ($frameCount - 1) . " 1 new 2", + ($frameCount) . " 0 hello 5 a", + ($frameCount) . " 1 hello 6 a", + ]; + self::assertSame($a, $summariseMetaData()); + + + + // test type-a with identifier 456 (STRING identifier) (i.e. test identifier TYPE) + MetaCallStackAPI::replaceMetaData('type-a', '456', 'new 5'); + $a = [ + ($frameCount - 2) . " 0 new 1", + ($frameCount - 2) . " 1 hello 2 b", + ($frameCount - 1) . " 0 new 1", + ($frameCount - 1) . " 1 new 2", + ($frameCount) . " 0 new 5", + ($frameCount) . " 1 hello 6 a", + ]; + self::assertSame($a, $summariseMetaData()); + } + + + + + + /** + * Test that the InternalSettings::META_DATA_TYPE__CONTROL_CALL meta-data is removed from the top of the stack. + * + * @test + * + * @return void + */ + public static function test_that_control_run_meta_is_removed_from_top(): void + { + MetaCallStackAPI::pushMultipleMetaData(InternalSettings::META_DATA_TYPE__CONTROL_CALL, null, ['call-one']); + MetaCallStackAPI::pushMultipleMetaData(InternalSettings::META_DATA_TYPE__CONTEXT, null, ['context-one']); + + $meta = Support::getGlobalMetaCallStack()->getStackMetaData(); + $key = array_key_last($meta); + self::assertCount(2, $meta[$key]); + self::assertSame(['call-one', 'context-one'], [$meta[$key][0]['value'], $meta[$key][1]['value']]); + + + + MetaCallStackAPI::pushMultipleMetaData( + InternalSettings::META_DATA_TYPE__CONTEXT, + null, + ['context-two'], + 0, + [InternalSettings::META_DATA_TYPE__CONTEXT], // <<< remove these from the top + ); + + $meta = Support::getGlobalMetaCallStack()->getStackMetaData(); + self::assertCount(2, $meta[$key]); + self::assertSame(['call-one', 'context-two'], [$meta[$key][0]['value'], $meta[$key][1]['value']]); + + + + // check that Clarity::context(…) removes the InternalSettings::META_DATA_TYPE__CONTROL_CALL meta-data + Clarity::context('context-three'); + + $meta = Support::getGlobalMetaCallStack()->getStackMetaData(); + self::assertCount(2, $meta[$key]); + self::assertSame(['context-two', 'context-three'], [$meta[$key][0]['value'], $meta[$key][1]['value']]); + } +} diff --git a/tests/Unit/ClarityUnitTest.php b/tests/Unit/ClarityUnitTest.php new file mode 100644 index 0000000..a3fadb7 --- /dev/null +++ b/tests/Unit/ClarityUnitTest.php @@ -0,0 +1,306 @@ + $expected The expected meta-data values. + * @return void + */ + public static function test_the_addition_of_meta_data( + string|array $arg1, + string|array|null $arg2, + string|array|null $arg3, + array $expected, + ): void { + + // add some meta-data + if ((!is_null($arg3)) && (!is_null($arg2))) { + Clarity::context($arg1, $arg2, $arg3); + } elseif (!is_null($arg2)) { + Clarity::context($arg1, $arg2); + } else { + Clarity::context($arg1); + } + + // check what was added + $metaCallStack = Support::getGlobalMetaCallStack(); + $metaData = $metaCallStack->getStackMetaData(); + $lastFrameIndex = max(array_keys(($metaData))); + + $foundContext = []; + /** @var array $metaData */ + foreach ($metaData[$lastFrameIndex] as $metaData) { + $foundContext[] = $metaData['value']; + } + + self::assertSame($expected, $foundContext); + } + + /** + * Test that Clarity doesn't add meta-data when disabled. + * + * @test + * @dataProvider metaDataCombinationDataProvider + * + * @param string|string[] $arg1 The first argument to pass. + * @param string|string[]|null $arg2 The second argument to pass (when not null). + * @param string|string[]|null $arg3 The third argument to pass (when not null). + * @return void + */ + public static function test_the_addition_of_meta_data_when_disabled( + string|array $arg1, + string|array|null $arg2, + string|array|null $arg3, + ): void { + + LaravelConfigHelper::disableClarity(); + + // add some meta-data + if ((!is_null($arg3)) && (!is_null($arg2))) { + Clarity::context($arg1, $arg2, $arg3); + } elseif (!is_null($arg2)) { + Clarity::context($arg1, $arg2); + } else { + Clarity::context($arg1); + } + + // check what was added - always empty - no MetaData objects + self::assertEmpty(Support::getGlobalMetaCallStack()->getStackMetaData()); + } + + /** + * DataProvider for test_the_addition_of_meta_data() and test_the_addition_of_meta_data_when_disabled(). + * + * @return array|string|null>> + */ + public static function metaDataCombinationDataProvider(): array + { + return [ + [ + 'arg1' => 'some context1', + 'arg2' => null, + 'arg3' => null, + 'expected' => [ + 'some context1', + ], + ], + [ + 'arg1' => ['some context1'], + 'arg2' => null, + 'arg3' => null, + 'expected' => [ + ['some context1'], + ], + ], + [ + 'arg1' => 'some context1', + 'arg2' => 'some context2', + 'arg3' => null, + 'expected' => [ + 'some context1', + 'some context2', + ], + ], + [ + 'arg1' => 'some context1', + 'arg2' => ['some context2'], + 'arg3' => null, + 'expected' => [ + 'some context1', + ['some context2'], + ], + ], + [ + 'arg1' => 'some context1', + 'arg2' => 'some context2', + 'arg3' => 'some context3', + 'expected' => [ + 'some context1', + 'some context2', + 'some context3', + ], + ], + [ + 'arg1' => 'some context1', + 'arg2' => 'some context2', + 'arg3' => ['some context3'], + 'expected' => [ + 'some context1', + 'some context2', + ['some context3'], + ], + ], + ]; + } + + + + /** + * Test that Clarity can build a context object in arbitrary places (not based on an exception). + * + * @test + * + * @return void + */ + public static function test_build_context_here_method(): void + { + $frameCount = count(debug_backtrace()) + 1; + + + + // test with no $framesBack specified + $context = Clarity::buildContextHere(); + self::assertInstanceOf(Context::class, $context); + self::assertCount($frameCount, $context->getCallStack()); + + // have a quick look at the location of the top frame + $path = '/tests/Unit/ClarityUnitTest.php'; + $path = str_replace('/', DIRECTORY_SEPARATOR, $path); + /** @var Frame[] $trace */ + $trace = $context->getStackTrace(); + self::assertSame(__FILE__, $trace[0]->getFile()); + self::assertSame($path, $trace[0]->getProjectFile()); + self::assertSame(__LINE__ - 11, $trace[0]->getLine()); + + + + // test with some $framesBack specified + $context = Clarity::buildContextHere(1); + self::assertCount($frameCount - 1, $context->getCallStack()); + + // have a quick look at the location of the 2nd top frame + $path = '/vendor/phpunit/phpunit/src/Framework/TestCase.php'; + $path = str_replace('/', DIRECTORY_SEPARATOR, $path); + /** @var Frame[] $trace */ + $trace = $context->getStackTrace(); + self::assertStringEndsWith($path, $trace[0]->getFile()); + self::assertStringEndsWith($path, $trace[0]->getProjectFile()); + + + + // test going back too many frames + $caughtException = false; + try { + // generate an exception + Clarity::buildContextHere($frameCount); // invalid frames back + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertTrue($caughtException); + + + + // test an invalid number of steps to go back + $caughtException = false; + try { + // generate an exception + Clarity::buildContextHere(-1); // invalid frames back + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertTrue($caughtException); + } + + + + /** + * Test that Clarity can retrieve exception's Context objects. + * + * @test + * + * @return void + */ + public static function test_retrieval_of_exception_context_objects(): void + { + // no "latest" Context yet + self::assertNull(ContextAPI::getLatestExceptionContext()); + + // get - when the exception's Context wasn't set (the Context will be built) + $e = new Exception(); + $context = Clarity::getExceptionContext($e); + self::assertSame($e, $context->getException()); + + // it was built and the "latest" Context is now available + self::assertSame($context, ContextAPI::getLatestExceptionContext()); + + // when the exception's Context was already set + $context2 = Clarity::getExceptionContext($e); + self::assertSame($context, $context2); + self::assertSame($e, $context2->getException()); + + // the latest context is set now + self::assertSame($context, ContextAPI::getLatestExceptionContext()); + + // generate a Context for a different exception + $e2 = new Exception(); + self::assertNotSame($context, Clarity::getExceptionContext($e2)); + } + + + + /** + * Test that Clarity can set trace identifiers. + * + * @test + * + * @return void + */ + public static function test_the_addition_of_trace_identifiers(): void + { + // no identifiers yet + self::assertEmpty(DataAPI::getTraceIdentifiers()); + + $identifiers = []; + + // 1 unnamed identifier + $identifiers[''] = 'abc'; + Clarity::traceIdentifier('abc'); + self::assertSame($identifiers, DataAPI::getTraceIdentifiers()); + + // 1 unnamed identifier (overwrite it) + $identifiers[''] = 'def'; + Clarity::traceIdentifier('def'); + self::assertSame($identifiers, DataAPI::getTraceIdentifiers()); + + // 1 unnamed and 1 named identifier + $identifiers['ghi'] = 123; + Clarity::traceIdentifier(123, 'ghi'); + self::assertSame($identifiers, DataAPI::getTraceIdentifiers()); + + // 1 unnamed and 1 named identifier (overwrite the new named one) + $identifiers['ghi'] = 456; + Clarity::traceIdentifier(456, 'ghi'); + self::assertSame($identifiers, DataAPI::getTraceIdentifiers()); + + // 1 unnamed and 2 named identifiers + $identifiers['jkl'] = 'xyz'; + Clarity::traceIdentifier('xyz', 'jkl'); + self::assertSame($identifiers, DataAPI::getTraceIdentifiers()); + } +} diff --git a/tests/Unit/ContextUnitTest.php b/tests/Unit/ContextUnitTest.php new file mode 100644 index 0000000..5976369 --- /dev/null +++ b/tests/Unit/ContextUnitTest.php @@ -0,0 +1,675 @@ + 'abc']; + $channels = ['stack']; + $level = Settings::REPORTING_LEVEL_DEBUG; + $report = true; + $rethrow = false; + $default = 'default1'; + + $basePath = (string) realpath(base_path('../../../..')); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $basePath); + + $context = new Context( + $exception, + null, + new MetaCallStack(), + -1, + $traceIdentifiers, + $projectRootDir, + $channels, + $level, + $report, + $rethrow, + $default, + ); + + self::assertSame(1, Context::CONTEXT_VERSION); + self::assertSame($exception, $context->getException()); + self::assertSame($traceIdentifiers, $context->getTraceIdentifiers()); + // tested more below in test_generation_of_callstack_and_stack_trace_xxx methods + self::assertInstanceOf(CallStack::class, $context->getCallStack()); + // tested more below in test_generation_of_callstack_and_stack_trace_xxx methods + self::assertInstanceOf(CallStack::class, $context->getStackTrace()); + self::assertSame($channels, $context->getChannels()); + self::assertSame($level, $context->getLevel()); + self::assertSame($report, $context->getReport()); + self::assertSame($rethrow, $context->getRethrow()); + self::assertSame($default, $context->getDefault()); + + $context->suppress(); + self::assertSame(false, $context->getReport()); + self::assertSame(false, $context->getRethrow()); + + $context->setKnown([]); + self::assertSame([], $context->getKnown()); + self::assertFalse($context->hasKnown()); + + + + // UPDATE the Context object's values + $traceIdentifiers = ['' => 'abc', 'def' => 'ghi']; + $known = ['Ratione quis aliquid velit.']; + $channels = ['daily']; + $level = null; + $report = false; + $rethrow = true; + $default = 'default2'; + + $context + ->setTraceIdentifiers($traceIdentifiers) + ->setKnown($known) + ->setChannels($channels) + ->setLevel($level) + ->setReport($report) + ->setRethrow($rethrow) + ->setDefault($default); + + self::assertSame($traceIdentifiers, $context->getTraceIdentifiers()); + self::assertSame($known, $context->getKnown()); + self::assertTrue($context->hasKnown()); + self::assertSame($channels, $context->getChannels()); + self::assertSame($level, $context->getLevel()); + self::assertSame($report, $context->getReport()); + self::assertSame($rethrow, $context->getRethrow()); + self::assertSame($default, $context->getDefault()); + + $closure = fn() => true; + $context->setRethrow($closure); // set a closure + self::assertSame($closure, $context->getRethrow()); + + $exception = new Exception(); + $context->setRethrow($exception); // set an exception + self::assertSame($exception, $context->getRethrow()); + + $context + ->setReport() // without parameters + ->setRethrow(); // without parameters + self::assertSame(true, $context->getReport()); + self::assertSame(true, $context->getRethrow()); + + $context + ->dontReport() + ->dontRethrow(); + self::assertSame(false, $context->getReport()); + self::assertSame(false, $context->getRethrow()); + + + + // update the Context object with different types of values at the same time + $context->setKnown('Aspernatur accusantium ut.', ['Quis delectus et ratione.']); + self::assertSame(['Aspernatur accusantium ut.', 'Quis delectus et ratione.'], $context->getKnown()); + + $context->setChannels('daily', ['something']); + self::assertSame(['daily', 'something'], $context->getChannels()); + + + + // set different log reporting levels + $context->debug(); + self::assertSame(Settings::REPORTING_LEVEL_DEBUG, $context->getLevel()); + $context->info(); + self::assertSame(Settings::REPORTING_LEVEL_INFO, $context->getLevel()); + $context->notice(); + self::assertSame(Settings::REPORTING_LEVEL_NOTICE, $context->getLevel()); + $context->warning(); + self::assertSame(Settings::REPORTING_LEVEL_WARNING, $context->getLevel()); + $context->error(); + self::assertSame(Settings::REPORTING_LEVEL_ERROR, $context->getLevel()); + $context->critical(); + self::assertSame(Settings::REPORTING_LEVEL_CRITICAL, $context->getLevel()); + $context->alert(); + self::assertSame(Settings::REPORTING_LEVEL_ALERT, $context->getLevel()); + $context->emergency(); + self::assertSame(Settings::REPORTING_LEVEL_EMERGENCY, $context->getLevel()); + } + + + + /** + * Test the retrieval of the callstack and stack trace from the Context object. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_retrieval_of_callstack_and_stack_trace(): void + { + $context = new Context( + new Exception(), + null, + new MetaCallStack(), + -1, + [], + Framework::config()->getProjectRootDir(), + [], + null, + true, + false, + null, + ); + + // the callstack and stack trace objects are cloned each time (the frames won't be) + self::assertNotSame($context->getCallStack(), $context->getCallStack()); + self::assertNotSame($context->getStackTrace(), $context->getStackTrace()); + + // the LAST frame from a callstack will be from this file + /** @var Frame[] $callStack */ + $callStack = $context->getCallStack(); + $lastIndex = count($callStack) - 1; + self::assertNotSame(__FILE__, $callStack[0]->getFile()); + self::assertSame(__FILE__, $callStack[$lastIndex]->getFile()); + + // the FIRST frame from a stack trace will be from this file + /** @var Frame[] $stackTrace */ + $stackTrace = $context->getStackTrace(); + $lastIndex = count($stackTrace) - 1; + self::assertSame(__FILE__, $stackTrace[0]->getFile()); + self::assertNotSame(__FILE__, $stackTrace[$lastIndex]->getFile()); + } + + + + /** + * Test the methods that fetch values from a Context object separately. + * + * @test + * + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_context_fetching_methods_separately(): void + { + $fakeId = mt_rand(); + + $traceIdentifiers = ['' => 'abc', 'def' => 'ghi']; + $known = ["Ratione quis aliquid velit. $fakeId"]; + $channels = ['stack']; + $level = 'info'; + $report = true; + $rethrow = false; + $default = mt_rand(); + + + + $callbacks = []; + $callbacks[] = fn(Context $context, Throwable $e) => self::assertSame($e, $context->getException()); + $callbacks[] = fn(Context $context) => self::assertInstanceOf(CallStack::class, $context->getCallStack()); + $callbacks[] = fn(Context $context) => self::assertInstanceOf(CallStack::class, $context->getStackTrace()); + $callbacks[] = fn(Context $context) => self::assertSame($traceIdentifiers, $context->getTraceIdentifiers()); + $callbacks[] = fn(Context $context) => self::assertSame($known, $context->getKnown()); + $callbacks[] = fn(Context $context) => self::assertSame((bool) count($known), $context->hasKnown()); + $callbacks[] = fn(Context $context) => self::assertSame($channels, $context->getChannels()); + $callbacks[] = fn(Context $context) => self::assertSame($level, $context->getLevel()); + $callbacks[] = fn(Context $context) => self::assertSame($report, $context->getReport()); + $callbacks[] = fn(Context $context) => self::assertSame($rethrow, $context->getRethrow()); + $callbacks[] = fn(Context $context) => self::assertSame($default, $context->getDefault()); + $callbacks[] = fn(Context $context) => self::assertFalse($context->detailsAreWorthListing()); + + + + foreach ($callbacks as $callback) { + + $e = new Exception(); + $context = new Context( + $e, + null, + new MetaCallStack(), + -1, + $traceIdentifiers, + Framework::config()->getProjectRootDir(), + $channels, + $level, + $report, + $rethrow, + $default, + ); + $context->setKnown($known); + + $callback($context, $e); + } + } + + + + /** + * Test that the Context object generates callstacks and stack traces based on an exception. + * + * @test + * + * @return void + */ + public static function test_generation_of_callstack_and_stack_trace_based_on_an_exception(): void + { + $exception = new Exception(); + + $basePath = (string) realpath(base_path('../../../..')); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $basePath); + + $context = new Context( + $exception, + null, + new MetaCallStack(), + null, + [], + $projectRootDir, + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + + + + // build a representation of the frames based on the exception's stack trace + $exceptionStackTrace = array_reverse($exception->getTrace()); + $exceptionCallStackFrames = []; + $function = '[top]'; + foreach ($exceptionStackTrace as $frame) { + $exceptionCallStackFrames[] = [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'function' => $function, + ]; + $function = $frame['function']; // shift the function by 1 frame + } + // add the exception's location as a frame + $exceptionCallStackFrames[] = [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'function' => $function, + ]; + $exceptionStackTraceFrames = array_reverse($exceptionCallStackFrames); + + + + // build a representation of the frames based on Clarity's callstack + $callstackFrames = []; + foreach ($context->getCallStack() as $frame) { + $callstackFrames[] = [ + 'file' => $frame->getFile(), + 'line' => $frame->getLine(), + 'function' => $frame->getFunction(), + ]; + } + + + + // build a representation of the frames based on Clarity's stack trace + $stackTraceFrames = []; + foreach ($context->getStackTrace() as $frame) { + $stackTraceFrames[] = [ + 'file' => $frame->getFile(), + 'line' => $frame->getLine(), + 'function' => $frame->getFunction(), + ]; + } + + + + // check they're the same + self::assertSame($exceptionCallStackFrames, $callstackFrames); + self::assertSame($exceptionStackTraceFrames, $stackTraceFrames); + } + + + + /** + * Test that the Context object generates callstacks and stack traces based on a php stack trace. + * + * @test + * + * @return void + */ + public static function test_generation_of_callstack_and_stack_trace_based_on_a_php_stack_trace(): void + { + $phpStackTrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS); + + $basePath = (string) realpath(base_path('../../../..')); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $basePath); + + $context = new Context( + null, + $phpStackTrace, + new MetaCallStack(), + -1, + [], + $projectRootDir, + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + + + + // build a representation of the frames based on PHP's stack trace + $phpCallstackFrames = []; + $function = '[top]'; + foreach (array_reverse($phpStackTrace) as $frame) { + $phpCallstackFrames[] = [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'function' => $function, + ]; + $function = $frame['function']; // shift the function by 1 frame + } + $phpStackTraceFrames = array_reverse($phpCallstackFrames); + + + + // build a representation of the frames based on Clarity's callstack + $callstackFrames = []; + foreach ($context->getCallStack() as $frame) { + $callstackFrames[] = [ + 'file' => $frame->getFile(), + 'line' => $frame->getLine(), + 'function' => $frame->getFunction(), + ]; + } + + + + // build a representation of the frames based on Clarity's stack trace + $stackTraceFrames = []; + foreach ($context->getStackTrace() as $frame) { + $stackTraceFrames[] = [ + 'file' => $frame->getFile(), + 'line' => $frame->getLine(), + 'function' => $frame->getFunction(), + ]; + } + + + + // check they're the same + self::assertSame($phpCallstackFrames, $callstackFrames); + self::assertSame($phpStackTraceFrames, $stackTraceFrames); + } + + + + /** + * Test that a Context populates the CallMeta "caughtHere" value when built based on an exception. + * + * @test + * + * @return void + */ + public static function test_the_context_class_can_identify_the_caught_here_frame_when_based_on_an_exception(): void + { + $catcherObjectId = 1; + + $exception = SimulateControlPackage::pushControlCallMetaAndGenerateException($catcherObjectId); + + $context = new Context( + $exception, + null, + Support::getGlobalMetaCallStack(), + $catcherObjectId, + [], + Framework::config()->getProjectRootDir(), + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + + // check that the CallMeta "catches" the exception + $callMeta = $context->getCallStack()->getMeta(CallMeta::class)[0] ?? null; + self::assertInstanceOf(CallMeta::class, $callMeta); + self::assertTrue($callMeta->wasCaughtHere()); + } + + + + /** + * Test that a Context object can be built based on a php stack trace. + * + * @test + * + * @return void + */ + public static function test_the_context_class_wont_identify_the_caught_here_frame_when_based_on_stack_trace(): void + { + $catcherObjectId = 1; + + SimulateControlPackage::pushControlCallMetaHere($catcherObjectId); + + $phpStackTrace = PHPStackTraceHelper::buildPHPStackTraceHere(); + + $context = new Context( + null, + $phpStackTrace, + Support::getGlobalMetaCallStack(), + $catcherObjectId, + [], + Framework::config()->getProjectRootDir(), + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + + // check that the CallMeta doesn't "catch" the exception (because no exception exists) + $callMeta = $context->getCallStack()->getMeta(CallMeta::class)[0] ?? null; + self::assertInstanceOf(CallMeta::class, $callMeta); // the CallMeta will exist + self::assertFalse($callMeta->wasCaughtHere()); // but it won't have "caught" the exception, because + // there was no exception + } + + + + /** + * Test the code that determines whether the Context is "worth reporting". + * + * @test + * + * @return void + */ + public static function test_worth_reporting(): void + { + $buildContext = function (MetaCallStack $metaCallStack, ?int $catcherObjectId = null): Context { + return new Context( + new Exception(), + null, + $metaCallStack, + $catcherObjectId, + [], + Framework::config()->getProjectRootDir(), + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + }; + + + + // when there is only a ExceptionThrownMeta and LastApplicationFrameMeta + $context = $buildContext(new MetaCallStack()); + self::assertFalse($context->detailsAreWorthListing()); + + // when there is a ContextMeta as well + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues( + InternalSettings::META_DATA_TYPE__CONTEXT, + null, + ['hello'], + 0 + ); + + $context = $buildContext($metaCallStack); + self::assertTrue($context->detailsAreWorthListing()); + } + + + + /** + * Ensure no meta-data objects are added when Clarity is disabled. + * + * @test + * @dataProvider clarityEnabledDataProvider + * + * @param boolean $enabled Whether Clarity is enabled or not. + * @return void + */ + public static function test_that_meta_objects_arent_created_when_clarity_is_disabled(bool $enabled): void + { + $enabled + ? LaravelConfigHelper::enableClarity() + : LaravelConfigHelper::disableClarity(); + + + + $catcherObjectId = 1; + $clarityMeta = [ + 'object-id' => $catcherObjectId, + 'known' => [], + ]; + + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues( + InternalSettings::META_DATA_TYPE__CONTROL_CALL, + $catcherObjectId, + [$clarityMeta], + 0 + ); + $metaCallStack->pushMultipleMetaDataValues( + InternalSettings::META_DATA_TYPE__CONTEXT, + null, + ['some context'], + 0 + ); + + $exception = new Exception(); + + $context = new Context( + $exception, + null, + $metaCallStack, + $catcherObjectId, + [], + Framework::config()->getProjectRootDir(), + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + + + + $callStack = $context->getCallStack(); + + if ($enabled) { + self::assertCount(5, $callStack->getMeta()); + self::assertCount(1, $callStack->getMeta(CallMeta::class)); + self::assertCount(1, $callStack->getMeta(ContextMeta::class)); + self::assertCount(1, $callStack->getMeta(LastApplicationFrameMeta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionThrownMeta::class)); + self::assertCount(1, $callStack->getMeta(ExceptionCaughtMeta::class)); + } else { + // no meta objects when disabled + self::assertCount(0, $callStack->getMeta()); + } + } + + /** + * DataProvider for test_when_clarity_is_disabled(). + * + * @return array> + */ + public static function clarityEnabledDataProvider(): array + { + return [ + ['enabled' => true], + ['enabled' => false], + ]; + } + + + + /** + * Check what happens when an invalid meta-data type is encountered. + * + * @test + * + * @return void + */ + public static function test_when_an_invalid_meta_data_type_is_encountered(): void + { + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('invalid', null, ['some context'], 0); + + $context = new Context( + new Exception(), + null, + $metaCallStack, + null, + [], + Framework::config()->getProjectRootDir(), + [], + Settings::REPORTING_LEVEL_DEBUG, + true, + true, + '', + ); + + // check that an exception is thrown + $caughtException = false; + try { + $context->getCallStack(); + } catch (ClarityContextInitialisationException) { + $caughtException = true; + } + self::assertTrue($caughtException); + } +} diff --git a/tests/Unit/Exceptions/ExceptionUnitTest.php b/tests/Unit/Exceptions/ExceptionUnitTest.php new file mode 100644 index 0000000..b4de401 --- /dev/null +++ b/tests/Unit/Exceptions/ExceptionUnitTest.php @@ -0,0 +1,57 @@ +getMessage() + ); + + self::assertSame( + 'Level "blah" is not allowed. ' + . 'Please choose from: debug, info, notice, warning, error, critical, alert, emergency', + ClarityContextInitialisationException::levelNotAllowed('blah')->getMessage() + ); + + self::assertSame( + 'Invalid meta type "invalid"', + ClarityContextInitialisationException::invalidMetaType('invalid')->getMessage() + ); + + + + // ClarityContextRuntimeException + + self::assertSame( + 'Invalid number of frames to go back: -1', + ClarityContextRuntimeException::invalidFramesBack(-1)->getMessage() + ); + + self::assertSame( + "Can't go back that many frames: 5 (current number of frames: 4, there must be at least one left)", + ClarityContextRuntimeException::tooManyFramesBack(5, 4)->getMessage() + ); + } +} diff --git a/tests/Unit/Support/CallStack/CallStackFramesUnitTest.php b/tests/Unit/Support/CallStack/CallStackFramesUnitTest.php new file mode 100644 index 0000000..8510d1d --- /dev/null +++ b/tests/Unit/Support/CallStack/CallStackFramesUnitTest.php @@ -0,0 +1,196 @@ +getLastApplicationFrameIndex()); + self::assertSame($frame3, $callStack->getLastApplicationFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false); + $frame2 = self::buildCallStackFrame('/var/www/html/src/some-file2', '/var/www/html', true); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(1, $callStack->getLastApplicationFrameIndex()); + self::assertSame($frame2, $callStack->getLastApplicationFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', true); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(0, $callStack->getLastApplicationFrameIndex()); + self::assertSame($frame1, $callStack->getLastApplicationFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false); + $frame3 = self::buildCallStackFrame('/var/www/html/src/some-file3', '/var/www/html', true); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(2, $callStack->getLastApplicationFrameIndex()); + self::assertSame($frame3, $callStack->getLastApplicationFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/vendor/some-file1', '/var/www/html', false); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(null, $callStack->getLastApplicationFrameIndex()); + self::assertSame(null, $callStack->getLastApplicationFrame()); + } + + + + /** + * Test that CallStack can find the "exception thrown" frame properly. + * + * @test + * + * @return void + */ + public static function test_accessing_the_thrown_here_frame(): void + { + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/src/some-file2', '/var/www/html', false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/src/some-file3', '/var/www/html', true, true); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(2, $callStack->getExceptionThrownFrameIndex()); + self::assertSame($frame3, $callStack->getExceptionThrownFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/src/some-file2', '/var/www/html', true, true); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false, false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(1, $callStack->getExceptionThrownFrameIndex()); + self::assertSame($frame2, $callStack->getExceptionThrownFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', true, true); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false, false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(0, $callStack->getExceptionThrownFrameIndex()); + self::assertSame($frame1, $callStack->getExceptionThrownFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/src/some-file3', '/var/www/html', true, true); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(2, $callStack->getExceptionThrownFrameIndex()); + self::assertSame($frame3, $callStack->getExceptionThrownFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/vendor/some-file1', '/var/www/html', false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false, false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(null, $callStack->getExceptionThrownFrameIndex()); + self::assertSame(null, $callStack->getExceptionThrownFrame()); + } + + + + /** + * Test that CallStack can find the "exception caught" frame properly. + * + * @test + * + * @return void + */ + public static function test_accessing_the_caught_here_frame(): void + { + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false, false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/src/some-file2', '/var/www/html', false, false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/src/some-file3', '/var/www/html', true, true, true); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(2, $callStack->getExceptionCaughtFrameIndex()); + self::assertSame($frame3, $callStack->getExceptionCaughtFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false, false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/src/some-file2', '/var/www/html', true, true, true); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false, false, false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(1, $callStack->getExceptionCaughtFrameIndex()); + self::assertSame($frame2, $callStack->getExceptionCaughtFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', true, true, true); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false, false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false, false, false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(0, $callStack->getExceptionCaughtFrameIndex()); + self::assertSame($frame1, $callStack->getExceptionCaughtFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/src/some-file1', '/var/www/html', false, false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false, false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/src/some-file3', '/var/www/html', true, true, true); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(2, $callStack->getExceptionCaughtFrameIndex()); + self::assertSame($frame3, $callStack->getExceptionCaughtFrame()); + + $frame1 = self::buildCallStackFrame('/var/www/html/vendor/some-file1', '/var/www/html', false, false, false); + $frame2 = self::buildCallStackFrame('/var/www/html/vendor/some-file2', '/var/www/html', false, false, false); + $frame3 = self::buildCallStackFrame('/var/www/html/vendor/some-file3', '/var/www/html', false, false, false); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame(null, $callStack->getExceptionCaughtFrameIndex()); + self::assertSame(null, $callStack->getExceptionCaughtFrame()); + } + + + + /** + * Build a dummy CallStackFrame object. + * + * @param string $file The file to use. + * @param string $projectRootDir The project-root-dir to use. + * @param boolean $isLastApplicationFrame Whether this is the last application frame or not. + * @param boolean $thrownHere Whether the exception was thrown in this frame or not. + * @param boolean $caughtHere Whether the exception was caught in this frame or not. + * @return Frame + */ + private static function buildCallStackFrame( + string $file = 'some-file', + string $projectRootDir = '', + bool $isLastApplicationFrame = false, + bool $thrownHere = false, + bool $caughtHere = false, + ): Frame { + + $file = (string) str_replace('/', DIRECTORY_SEPARATOR, $file); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $projectRootDir); + + $projectFile = Support::resolveProjectFile($file, $projectRootDir); + $isApplicationFrame = Support::isApplicationFile($projectFile, $projectRootDir); + + return new Frame( + [ + 'file' => $file, + 'line' => mt_rand(), + ], + $projectFile, + [], + $isApplicationFrame, + $isLastApplicationFrame, + false, + $thrownHere, + $caughtHere, + ); + } +} diff --git a/tests/Unit/Support/CallStack/CallStackMetaGroupUnitTest.php b/tests/Unit/Support/CallStack/CallStackMetaGroupUnitTest.php new file mode 100644 index 0000000..b22600c --- /dev/null +++ b/tests/Unit/Support/CallStack/CallStackMetaGroupUnitTest.php @@ -0,0 +1,769 @@ + $expected The expected groupings of Meta objects. + * @return void + * @throws Exception Doesn't throw this, but phpcs expects this to be here. + */ + public static function test_the_building_of_meta_groups( + callable $callable, + array $expected, + ): void { + + $callback = function (Context $context) use ($expected) { + + $callStackMetaGroups = $context->getCallStack()->getMetaGroups(); + $stackTraceMetaGroups = $context->getStackTrace()->getMetaGroups(); + self::assertIsArray($callStackMetaGroups); + self::assertIsArray($stackTraceMetaGroups); + + $found = []; + foreach ($callStackMetaGroups as $groupIndex => $metaGroup) { + foreach ($metaGroup->getMeta() as $metaIndex => $meta) { + $found[$groupIndex][$metaIndex] = get_class($meta); + } + } + if ($expected !== $found) { + throw new Exception('The generated callstack meta-groups were not expected'); + } + self::assertSame($expected, $found); + + $found = []; + foreach ($stackTraceMetaGroups as $groupIndex => $metaGroup) { + foreach ($metaGroup->getMeta() as $metaIndex => $meta) { + $found[$groupIndex][$metaIndex] = get_class($meta); + } + } + $expectedReverse = array_reverse($expected); + if ($expectedReverse !== $found) { + throw new Exception('The generated stack trace meta-groups were not expected'); + } + self::assertSame($expectedReverse, $found); + }; + + + + // reset state + self::$callable = null; + self::$resultingContext = null; + + $callable(); + + /** @var Context $resultingContext $callable() will set it to be a Context object. */ + $resultingContext = self::$resultingContext; + + $callback($resultingContext); + } + + /** + * DataProvider for test_the_building_of_meta_groups(). + * + * @return array>> + */ + public static function buildMetaGroupsDataProvider(): array + { + $prime = function (callable $callable) { + self::$callable = $callable; + }; + + $execute = function () { + + SimulateControlPackage::pushControlCallMetaHere(1, [], 1); + + /** @var callable $callable It is callable by this stage, because $prime has been run. */ + $callable = self::$callable; + $exception = null; + try { + $callable(); + } catch (Throwable $e) { + $exception = $e; + } + + /** @var Exception $exception It is an Exception by this stage, the primed closures all throw an exception. */ + self::$resultingContext = SimulateControlPackage::buildContext(1, $exception); + }; + + + + $return = []; + + + + // exception thrown in an APPLICATION frame + + + + // no context - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + $prime(fn() => throw new Exception()); $execute(); // phpcs:ignore +// Control::prime(fn() => throw new Exception())->callback($callback)->execute(); + }, + [ + [ + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ExceptionThrownMeta::class + ], + ], + ]; + + // with context - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + $prime(fn() => throw new Exception()); $execute(); // phpcs:ignore + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback)->execute(); + }, + [ + [ + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ExceptionThrownMeta::class, + ], + ], + ]; + + // with 2 x context (same line) - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + // two contexts on the same line - the second one is the one that's picked up + Clarity::context('hello'); Clarity::context(['a' => 'b']); // phpcs:ignore + $prime(fn() => throw new Exception()); $execute(); // phpcs:ignore + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback)->execute(); + }, + [ + [ + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ExceptionThrownMeta::class, + ], + ], + ]; + + // with 2 x context - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + $prime(fn() => throw new Exception()); $execute(); // phpcs:ignore + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback)->execute(); + }, + [ + [ + ContextMeta::class, + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ExceptionThrownMeta::class, + ], + ], + ]; + + // with 2 x context (with gap) - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + // blank line on purpose + Clarity::context(['a' => 'b']); + $prime(fn() => throw new Exception()); $execute(); // phpcs:ignore + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback)->execute(); + }, + [ + [ContextMeta::class], + [ + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ExceptionThrownMeta::class, + ], + ], + ]; + + + + + + + + // no context - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + $prime(fn() => throw new Exception()); + $execute(); + +// Control::prime(fn() => throw new Exception())->callback($callback) +// ->execute(); + }, + [ + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with context - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + $prime(fn() => throw new Exception()); + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (same line) - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + // two contexts on the same line - the second one is the one that's picked up + Clarity::context('hello'); Clarity::context(['a' => 'b']); // phpcs:ignore + $prime(fn() => throw new Exception()); + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + $prime(fn() => throw new Exception()); + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class, ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (with gap) - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + // blank line on purpose + Clarity::context(['a' => 'b']); + $prime(fn() => throw new Exception()); + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception())->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + + + + + + + // no context - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + $prime(fn() => throw new Exception()); + // blank line on purpose + $execute(); + +// Control::prime(fn() => throw new Exception()) +// ->callback($callback) +// ->execute(); + }, + [ + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with context - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + $prime(fn() => throw new Exception()); + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception()) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (same line) - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + // two contexts on the same line - the second one is the one that's picked up + Clarity::context('hello'); Clarity::context(['a' => 'b']); // phpcs:ignore + $prime(fn() => throw new Exception()); + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception()) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + $prime(fn() => throw new Exception()); + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception()) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class, ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (with gap) - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + // blank line on purpose + Clarity::context(['a' => 'b']); + $prime(fn() => throw new Exception()); + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw new Exception()) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class, ExceptionThrownMeta::class], + ], + ]; + + + + + + + + // exception thrown in a VENDOR frame + + + + // no context - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + $prime(fn() => app()->make(NonExistantClass::class)); $execute(); /** @phpstan-ignore-line */ // phpcs:ignore +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback)->execute(); + }, + [ + [ + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ], + [ExceptionThrownMeta::class], + ], + ]; + + // with context - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + $prime(fn() => app()->make(NonExistantClass::class)); $execute(); /** @phpstan-ignore-line */ // phpcs:ignore + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback)->execute(); + }, + [ + [ + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (same line) - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + // two contexts on the same line - the second one is the one that's picked up + Clarity::context('hello'); Clarity::context(['a' => 'b']); // phpcs:ignore + $prime(fn() => app()->make(NonExistantClass::class)); $execute(); /** @phpstan-ignore-line */ // phpcs:ignore + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback)->execute(); + }, + [ + [ + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + $prime(fn() => app()->make(NonExistantClass::class)); $execute(); /** @phpstan-ignore-line */ // phpcs:ignore + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback)->execute(); + }, + [ + [ + ContextMeta::class, + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (with gap) - simulate "Control" chaining on the same line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + // blank line on purpose + Clarity::context(['a' => 'b']); + $prime(fn() => app()->make(NonExistantClass::class)); $execute(); /** @phpstan-ignore-line */ // phpcs:ignore + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback)->execute(); + }, + [ + [ContextMeta::class], + [ + ContextMeta::class, + CallMeta::class, + ExceptionCaughtMeta::class, + LastApplicationFrameMeta::class, + ], + [ExceptionThrownMeta::class], + ], + ]; + + + + + + + + // no context - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + $execute(); + +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback) +// ->execute(); + }, + [ + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with context - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + $execute(); + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (same line) - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + // two contexts on the same line - the second one is the one that's picked up + Clarity::context('hello'); Clarity::context(['a' => 'b']); // phpcs:ignore + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + $execute(); + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + $execute(); + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class, ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (with gap) - prime() then execute() on the next line + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + // blank line on purpose + Clarity::context(['a' => 'b']); + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + $execute(); + +// Clarity::context('hello'); +// phpcs:ignore Control::prime(fn() => throw app()->make(NonExistantClass::class))->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + + + + + + + // no context - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + // blank line on purpose + $execute(); + +// Control::prime(fn() => throw app()->make(NonExistantClass::class)) +// ->callback($callback) +// ->execute(); + }, + [ + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with context - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw app()->make(NonExistantClass::class)) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (same line) - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + // two contexts on the same line - the second one is the one that's picked up + Clarity::context('hello'); Clarity::context(['a' => 'b']); // phpcs:ignore + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw app()->make(NonExistantClass::class)) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + Clarity::context(['a' => 'b']); + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw app()->make(NonExistantClass::class)) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class, ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + // with 2 x context (with gap) - prime() then execute() (with gap) + $return[] = [ + function () use ($prime, $execute) { + Clarity::context('hello'); + // blank line on purpose + Clarity::context(['a' => 'b']); + $prime(fn() => app()->make(NonExistantClass::class)); /** @phpstan-ignore-line */ + // blank line on purpose + $execute(); + +// Clarity::context('hello'); +// Control::prime(fn() => throw app()->make(NonExistantClass::class)) +// ->callback($callback) +// ->execute(); + }, + [ + [ContextMeta::class], + [ContextMeta::class], + [CallMeta::class, ExceptionCaughtMeta::class], + [LastApplicationFrameMeta::class], + [ExceptionThrownMeta::class], + ], + ]; + + return $return; + } +} diff --git a/tests/Unit/Support/CallStack/CallStackMetaUnitTest.php b/tests/Unit/Support/CallStack/CallStackMetaUnitTest.php new file mode 100644 index 0000000..e89ecff --- /dev/null +++ b/tests/Unit/Support/CallStack/CallStackMetaUnitTest.php @@ -0,0 +1,213 @@ +getMeta()); + + + + # Frames with no Meta objects + $frame1 = self::buildCallStackFrame([]); + $frame2 = self::buildCallStackFrame([]); + $frame3 = self::buildCallStackFrame([]); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame([], $callStack->getMeta()); + + + + # Frames with a Meta object + $frame1 = self::buildCallStackFrame([$contextMeta1]); + $frame2 = self::buildCallStackFrame([]); + $frame3 = self::buildCallStackFrame([]); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame([$contextMeta1], $callStack->getMeta()); + + + + # Frames with a Meta objects + $frame1 = self::buildCallStackFrame([$contextMeta1, $contextMeta2]); + $frame2 = self::buildCallStackFrame([]); + $frame3 = self::buildCallStackFrame([]); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame([$contextMeta1, $contextMeta2], $callStack->getMeta()); + + # Frames with a Meta objects + $frame1 = self::buildCallStackFrame([$contextMeta1]); + $frame2 = self::buildCallStackFrame([]); + $frame3 = self::buildCallStackFrame([$contextMeta2]); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame([$contextMeta1, $contextMeta2], $callStack->getMeta()); + + + + # Frames with a Meta objects - but request a type that doesn't exist + $frame1 = self::buildCallStackFrame([$contextMeta1, $callMeta1]); + $frame2 = self::buildCallStackFrame([$exceptionCaughtMeta]); + $frame3 = self::buildCallStackFrame([$contextMeta2, $exceptionThrownMeta]); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame([], $callStack->getMeta(LastApplicationFrameMeta::class)); + + # Frames with a Meta objects - and request a type that does exist + $frame1 = self::buildCallStackFrame([$contextMeta1, $callMeta1, $lastApplicationFrameMeta]); + $frame2 = self::buildCallStackFrame([$exceptionCaughtMeta]); + $frame3 = self::buildCallStackFrame([$contextMeta2, $exceptionThrownMeta]); + $callStack = new CallStack([$frame1, $frame2, $frame3]); + self::assertSame([$lastApplicationFrameMeta], $callStack->getMeta(LastApplicationFrameMeta::class)); + self::assertSame([$contextMeta1, $contextMeta2], $callStack->getMeta(ContextMeta::class)); + } + + + + /** + * Build a dummy CallMeta object. + * + * @return CallMeta + */ + private static function buildCallMeta(): CallMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new CallMeta($frameData, 'somewhere', false, []); + } + + /** + * Build a dummy ContextMeta object - containing a sentence. + * + * @return ContextMeta + */ + private static function buildContextMetaContainingSentence(): ContextMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new ContextMeta($frameData, 'somewhere', 'something'); + } + + /** + * Build a dummy ExceptionCaughtMeta object. + * + * @return ExceptionCaughtMeta + */ + private static function buildExceptionCaughtMeta(): ExceptionCaughtMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new ExceptionCaughtMeta($frameData, 'somewhere'); + } + + /** + * Build a dummy ExceptionCaughtMeta object. + * + * @return ExceptionThrownMeta + */ + private static function buildExceptionThrownMeta(): ExceptionThrownMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new ExceptionThrownMeta($frameData, 'somewhere'); + } + + /** + * Build a dummy ExceptionCaughtMeta object. + * + * @return LastApplicationFrameMeta + */ + private static function buildLastApplicationFrameMeta(): LastApplicationFrameMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new LastApplicationFrameMeta($frameData, 'somewhere'); + } + + + + /** + * Build a dummy CallStackFrame object. + * + * @param Meta[] $metaObjects The meta-objects the frame will have. + * @return Frame + */ + private static function buildCallStackFrame( + array $metaObjects, + ): Frame { + + $file = 'some-file'; + $projectRootDir = ''; + + $file = (string) str_replace('/', DIRECTORY_SEPARATOR, $file); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $projectRootDir); + + $projectFile = Support::resolveProjectFile($file, $projectRootDir); + $isApplicationFrame = Support::isApplicationFile($projectFile, $projectRootDir); + + return new Frame( + [ + 'file' => $file, + 'line' => mt_rand(), + ], + $projectFile, + $metaObjects, + $isApplicationFrame, + false, + false, + false, + false, + ); + } +} diff --git a/tests/Unit/Support/CallStack/CallStackPHPInterfacesUnitTest.php b/tests/Unit/Support/CallStack/CallStackPHPInterfacesUnitTest.php new file mode 100644 index 0000000..9b522ad --- /dev/null +++ b/tests/Unit/Support/CallStack/CallStackPHPInterfacesUnitTest.php @@ -0,0 +1,178 @@ +count()); + self::assertSame(3, count($callStack)); + self::assertCount(3, $callStack); + + + + // ArrayAccess + self::assertSame($frame1, $callStack[0]); + self::assertSame($frame2, $callStack[1]); + self::assertSame($frame3, $callStack[2]); + self::assertFalse(isset($callStack['blah'])); + self::assertFalse(isset($callStack[-1])); + self::assertTrue(isset($callStack[0])); + self::assertTrue(isset($callStack[1])); + self::assertTrue(isset($callStack[2])); + self::assertFalse(isset($callStack[3])); + self::assertCount(3, $callStack); + + $callStack[3] = $frame4; + self::assertSame($frame4, $callStack[3]); + self::assertTrue(isset($callStack[3])); + self::assertCount(4, $callStack); + + unset($callStack[3]); + self::assertFalse(isset($callStack[4])); + self::assertCount(3, $callStack); + + $exceptionOccurred = false; + try { + $callStack[0] = "Something that's not a Frame object"; + } catch (InvalidArgumentException) { + $exceptionOccurred = true; + } + self::assertTrue($exceptionOccurred); + + + + // SeekableIterator + + // loop through with foreach loop + $count = 0; + foreach ($callStack as $frame) { + if ($count == 0) { + self::assertSame($frame1, $frame); + } elseif ($count == 1) { + self::assertSame($frame2, $frame); + } elseif ($count == 2) { + self::assertSame($frame3, $frame); + } + $count++; + } + + // loop through manually + $callStack->rewind(); + self::assertSame(0, $callStack->key()); + self::assertSame($frame1, $callStack->current()); + $callStack->next(); + self::assertSame(1, $callStack->key()); + self::assertSame($frame2, $callStack->current()); + $callStack->next(); + self::assertSame(2, $callStack->key()); + self::assertSame($frame3, $callStack->current()); + $callStack->next(); + self::assertSame(3, $callStack->key()); + self::assertSame(null, $callStack->current()); + + // seek + $callStack->seek(1); + self::assertSame($frame2, $callStack->current()); + + $threwException = false; + try { + $callStack->seek(-1); + } catch (OutOfBoundsException) { + $threwException = true; + } + self::assertTrue($threwException); + + // reverse + $callStack->seek(1); + self::assertSame($frame2, $callStack->current()); + $callStack->reverse(); // will reset back to position 0, after reversing + self::assertSame($frame3, $callStack->current()); + $count = 0; + foreach ($callStack as $frame) { + if ($count == 0) { + self::assertSame($frame3, $frame); + } elseif ($count == 1) { + self::assertSame($frame2, $frame); + } elseif ($count == 2) { + self::assertSame($frame1, $frame); + } + $count++; + } + + + + // test that keys aren't preserved + $callStack = new CallStack(['a' => $frame1, 'b' => $frame2, 'c' => $frame3]); + self::assertTrue(isset($callStack[0])); + } + + + + /** + * Build a dummy CallStackFrame object. + * + * @return Frame + */ + private static function buildCallStackFrame(): Frame + { + $file = 'some-file'; + $projectRootDir = ''; + + $file = (string) str_replace('/', DIRECTORY_SEPARATOR, $file); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $projectRootDir); + + $projectFile = Support::resolveProjectFile($file, $projectRootDir); + $isApplicationFrame = Support::isApplicationFile($projectFile, $projectRootDir); + + return new Frame( + [ + 'file' => $file, + 'line' => mt_rand(), + ], + $projectFile, + [], + $isApplicationFrame, + false, + false, + false, + false, + ); + } +} diff --git a/tests/Unit/Support/CallStack/FrameUnitTest.php b/tests/Unit/Support/CallStack/FrameUnitTest.php new file mode 100644 index 0000000..be32638 --- /dev/null +++ b/tests/Unit/Support/CallStack/FrameUnitTest.php @@ -0,0 +1,291 @@ + $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'object' => $object, + 'type' => $type, + 'args' => $args, + ]; + + $frame = new Frame( + $frameData, + $projectFile, + $meta, + $isApplicationFrame, + $isLastApplicationFrame, + $isLastFrame, + $thrownHere, + $caughtHere, + ); + + $contextMetas = []; + foreach ($meta as $oneMeta) { + if ($oneMeta instanceof ContextMeta) { + $contextMetas[] = $oneMeta; + } + } + + self::assertSame((string) $file, $frame->getFile()); + self::assertSame($projectFile, $frame->getProjectFile()); + self::assertSame((int) $line, $frame->getLine()); + self::assertSame((string) $function, $frame->getFunction()); + self::assertSame((string) $class, $frame->getClass()); + self::assertSame($object, $frame->getObject()); + self::assertSame((string) $type, $frame->getType()); + self::assertSame($args, $frame->getArgs()); + self::assertSame($meta, $frame->getMeta()); + self::assertSame($contextMetas, $frame->getMeta(ContextMeta::class)); + self::assertSame($contextMetas, $frame->getMeta([ContextMeta::class])); + // check that the ContextMetas don't get doubled up + self::assertSame($meta, $frame->getMeta([ContextMeta::class, Meta::class])); + self::assertSame($isApplicationFrame, $frame->isApplicationFrame()); + self::assertSame($isLastApplicationFrame, $frame->isLastApplicationFrame()); + self::assertSame(!$isApplicationFrame, $frame->isVendorFrame()); + self::assertSame($isLastFrame, $frame->isLastFrame()); + self::assertSame($thrownHere, $frame->exceptionWasThrownHere()); + self::assertSame($caughtHere, $frame->exceptionWasCaughtHere()); + + self::_testBuildCopyWithExtraMeta($frame, false, false); + self::_testBuildCopyWithExtraMeta($frame, false, true); + self::_testBuildCopyWithExtraMeta($frame, true, false); + self::_testBuildCopyWithExtraMeta($frame, true, true); + + self::assertSame($frameData, $frame->getRawFrameData()); + } + + /** + * Build a dummy ContextMeta object - containing a sentence. + * + * @return ContextMeta + */ + private static function buildContextMetaContainingSentence(): ContextMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new ContextMeta($frameData, 'somewhere', 'something'); + } + + /** + * Build a dummy ContextMeta object - containing an array. + * + * @return ContextMeta + */ + private static function buildContextMetaContainingArray(): ContextMeta + { + $frameData = [ + 'file' => 'somewhere', + 'line' => 123, + ]; + + return new ContextMeta($frameData, 'somewhere', ['a' => 'b']); + } + + + + /** + * Test the buildCopyWithExtraMeta method. + * + * @param Frame $frame The frame to copy. + * @param boolean $thrownHere Whether the exception was thrown by this frame or not. + * @param boolean $isLastApplicationFrame Whether this is the last application frame or not. + * @return void + */ + private static function _testBuildCopyWithExtraMeta( + Frame $frame, + bool $thrownHere, + bool $isLastApplicationFrame + ): void { + + $contextMeta = self::buildContextMetaContainingSentence(); + $frame2 = $frame->buildCopyWithExtraMeta($contextMeta, $thrownHere, $isLastApplicationFrame); + + self::assertSame($frame->getFile(), $frame2->getFile()); + self::assertSame($frame->getProjectFile(), $frame2->getProjectFile()); + self::assertSame($frame->getLine(), $frame2->getLine()); + self::assertSame($frame->getFunction(), $frame2->getFunction()); + self::assertSame($frame->getClass(), $frame2->getClass()); + self::assertSame($frame->getObject(), $frame2->getObject()); + self::assertSame($frame->getType(), $frame2->getType()); + self::assertSame($frame->getArgs(), $frame2->getArgs()); + + $contextMetas = array_merge($frame->getMeta(), [$contextMeta]); + self::assertSame($contextMetas, $frame2->getMeta()); + self::assertSame($contextMetas, $frame2->getMeta(ContextMeta::class)); + self::assertSame($contextMetas, $frame2->getMeta([ContextMeta::class])); + self::assertSame($frame->isApplicationFrame(), $frame2->isApplicationFrame()); + $newIsLastApplicationFrame = $frame->isLastApplicationFrame() || $isLastApplicationFrame; + self::assertSame($newIsLastApplicationFrame, $frame2->isLastApplicationFrame()); + self::assertSame($frame->isVendorFrame(), $frame2->isVendorFrame()); + self::assertSame($frame->isLastFrame(), $frame2->isLastFrame()); + self::assertSame($frame->exceptionWasThrownHere() || $thrownHere, $frame2->exceptionWasThrownHere()); + self::assertSame($frame->exceptionWasCaughtHere(), $frame2->exceptionWasCaughtHere()); + } +} diff --git a/tests/Unit/Support/CallStack/MetaData/CallMetaUnitTest.php b/tests/Unit/Support/CallStack/MetaData/CallMetaUnitTest.php new file mode 100644 index 0000000..d257a0d --- /dev/null +++ b/tests/Unit/Support/CallStack/MetaData/CallMetaUnitTest.php @@ -0,0 +1,56 @@ +', '::'] as $type) { + + $frameData = [ + 'file' => $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'type' => $type, + ]; + + $meta = new CallMeta($frameData, $projectFile, $caughtHere, $known); + + self::assertSame($file, $meta->getFile()); + self::assertSame($projectFile, $meta->getProjectFile()); + self::assertSame($line, $meta->getLine()); + self::assertSame($function, $meta->getFunction()); + self::assertSame($class, $meta->getClass()); + self::assertSame($type, $meta->getType()); + self::assertSame($caughtHere, $meta->wasCaughtHere()); + self::assertSame($known, $meta->getKnown()); + } + } + } +} diff --git a/tests/Unit/Support/CallStack/MetaData/ContextMetaUnitTest.php b/tests/Unit/Support/CallStack/MetaData/ContextMetaUnitTest.php new file mode 100644 index 0000000..b73e6b4 --- /dev/null +++ b/tests/Unit/Support/CallStack/MetaData/ContextMetaUnitTest.php @@ -0,0 +1,59 @@ + $rand] + ]; + + foreach (['->', '::'] as $type) { + foreach ($contextValueCombinations as $context) { + + $frameData = [ + 'file' => $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'type' => $type, + ]; + + $meta = new ContextMeta($frameData, $projectFile, $context); + + self::assertSame($file, $meta->getFile()); + self::assertSame($projectFile, $meta->getProjectFile()); + self::assertSame($line, $meta->getLine()); + self::assertSame($function, $meta->getFunction()); + self::assertSame($class, $meta->getClass()); + self::assertSame($type, $meta->getType()); + self::assertSame($context, $meta->getContext()); + } + } + } +} diff --git a/tests/Unit/Support/CallStack/MetaData/ExceptionCaughtMetaUnitTest.php b/tests/Unit/Support/CallStack/MetaData/ExceptionCaughtMetaUnitTest.php new file mode 100644 index 0000000..c019115 --- /dev/null +++ b/tests/Unit/Support/CallStack/MetaData/ExceptionCaughtMetaUnitTest.php @@ -0,0 +1,51 @@ +', '::'] as $type) { + + $frameData = [ + 'file' => $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'type' => $type, + ]; + + $meta = new ExceptionCaughtMeta($frameData, $projectFile); + + self::assertSame($file, $meta->getFile()); + self::assertSame($projectFile, $meta->getProjectFile()); + self::assertSame($line, $meta->getLine()); + self::assertSame($function, $meta->getFunction()); + self::assertSame($class, $meta->getClass()); + self::assertSame($type, $meta->getType()); + } + } +} diff --git a/tests/Unit/Support/CallStack/MetaData/ExceptionThrownMetaUnitTest.php b/tests/Unit/Support/CallStack/MetaData/ExceptionThrownMetaUnitTest.php new file mode 100644 index 0000000..e5ecda8 --- /dev/null +++ b/tests/Unit/Support/CallStack/MetaData/ExceptionThrownMetaUnitTest.php @@ -0,0 +1,51 @@ +', '::'] as $type) { + + $frameData = [ + 'file' => $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'type' => $type, + ]; + + $meta = new ExceptionThrownMeta($frameData, $projectFile); + + self::assertSame($file, $meta->getFile()); + self::assertSame($projectFile, $meta->getProjectFile()); + self::assertSame($line, $meta->getLine()); + self::assertSame($function, $meta->getFunction()); + self::assertSame($class, $meta->getClass()); + self::assertSame($type, $meta->getType()); + } + } +} diff --git a/tests/Unit/Support/CallStack/MetaData/LastApplicationFrameMetaUnitTest.php b/tests/Unit/Support/CallStack/MetaData/LastApplicationFrameMetaUnitTest.php new file mode 100644 index 0000000..e2c08e6 --- /dev/null +++ b/tests/Unit/Support/CallStack/MetaData/LastApplicationFrameMetaUnitTest.php @@ -0,0 +1,51 @@ +', '::'] as $type) { + + $frameData = [ + 'file' => $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'type' => $type, + ]; + + $meta = new LastApplicationFrameMeta($frameData, $projectFile); + + self::assertSame($file, $meta->getFile()); + self::assertSame($projectFile, $meta->getProjectFile()); + self::assertSame($line, $meta->getLine()); + self::assertSame($function, $meta->getFunction()); + self::assertSame($class, $meta->getClass()); + self::assertSame($type, $meta->getType()); + } + } +} diff --git a/tests/Unit/Support/CallStack/MetaGroupUnitTest.php b/tests/Unit/Support/CallStack/MetaGroupUnitTest.php new file mode 100644 index 0000000..419d53a --- /dev/null +++ b/tests/Unit/Support/CallStack/MetaGroupUnitTest.php @@ -0,0 +1,135 @@ + $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'object' => new stdClass(), + 'type' => $type, + 'args' => [] + ]; + + $meta = new ExceptionThrownMeta($frameData, $projectFile); + $metaObjects = [$meta]; + + $frame = new Frame( + $frameData, + $projectFile, + $metaObjects, + $isInApplicationFrame, + $isInLastApplicationFrame, + $isInLastFrame, + $exceptionThrownInThisFrame, + $exceptionCaughtInThisFrame, + ); + + // test when instantiated via the constructor + $metaGroup1 = new MetaGroup( + $file, + $projectFile, + $line, + $function, + $class, + $type, + $metaObjects, + $isInApplicationFrame, + $isInLastApplicationFrame, + $isInLastFrame, + $exceptionThrownInThisFrame, + $exceptionCaughtInThisFrame, + ); + + self::assertSame($file, $metaGroup1->getFile()); + self::assertSame($projectFile, $metaGroup1->getProjectFile()); + self::assertSame($line, $metaGroup1->getLine()); + self::assertSame($function, $metaGroup1->getFunction()); + self::assertSame($class, $metaGroup1->getClass()); + self::assertSame($type, $metaGroup1->getType()); + self::assertSame($metaObjects, $metaGroup1->getMeta()); + self::assertSame($isInApplicationFrame, $metaGroup1->isInApplicationFrame()); + self::assertSame($isInLastApplicationFrame, $metaGroup1->isInLastApplicationFrame()); + self::assertSame(!$isInApplicationFrame, $metaGroup1->isInVendorFrame()); + self::assertSame($isInLastFrame, $metaGroup1->isInLastFrame()); + self::assertSame($exceptionThrownInThisFrame, $metaGroup1->exceptionThrownInThisFrame()); + self::assertSame($exceptionCaughtInThisFrame, $metaGroup1->exceptionCaughtInThisFrame()); + + // test when instantiated via the alternative method + $metaGroup2 = MetaGroup::newFromFrameAndMeta($frame, $meta, $metaObjects); + self::assertEquals($metaGroup1, $metaGroup2); + } + + /** + * DataProvider for test_meta_group_crud(). + * + * @return array> + */ + public static function metaGroupCrudDataProvider(): array + { + $return = []; + + foreach ([true, false] as $isInApplicationFrame) { + foreach ([true, false] as $isInLastApplicationFrame) { + foreach ([true, false] as $isInLastFrame) { + foreach ([true, false] as $exceptionThrownInThisFrame) { + foreach ([true, false] as $exceptionCaughtInThisFrame) { + + $return[] = [ + 'isInApplicationFrame' => $isInApplicationFrame, + 'isInLastApplicationFrame' => $isInLastApplicationFrame, + 'isInLastFrame' => $isInLastFrame, + 'exceptionThrownInThisFrame' => $exceptionThrownInThisFrame, + 'exceptionCaughtInThisFrame' => $exceptionCaughtInThisFrame, + ]; + } + } + } + } + } + + return $return; + } +} diff --git a/tests/Unit/Support/Framework/LaravelFrameworkConfigUnitTest.php b/tests/Unit/Support/Framework/LaravelFrameworkConfigUnitTest.php new file mode 100644 index 0000000..b9b390a --- /dev/null +++ b/tests/Unit/Support/Framework/LaravelFrameworkConfigUnitTest.php @@ -0,0 +1,370 @@ +getProjectRootDir() + ); + } + + + + /** + * Test the framework config crud functionality. + * + * @test + * + * @return void + */ + public static function test_framework_config_crud(): void + { + $config = Framework::config(); + $key = 'a.key'; + + + + // retrieving a BOOLEAN + + // when the value is a string - returns null + $config->updateConfig([$key => 'abc']); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is a string with commas - returns null + $config->updateConfig([$key => 'abc,def']); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is an empty string - returns null + $config->updateConfig([$key => '']); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is null - returns null + $config->updateConfig([$key => null]); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is an array - returns null + $config->updateConfig([$key => ['abc' => 'def']]); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is an empty array - returns null + $config->updateConfig([$key => []]); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is true - returns true + $config->updateConfig([$key => true]); + self::assertTrue($config->pickConfigBoolean($key)); + + // when the value is false - returns false + $config->updateConfig([$key => false]); + self::assertFalse($config->pickConfigBoolean($key)); + + // when the value is an integer - returns null + $config->updateConfig([$key => 123]); + self::assertNull($config->pickConfigBoolean($key)); + + // when the value is a float - returns null + $config->updateConfig([$key => 123.456]); + self::assertNull($config->pickConfigBoolean($key)); + + + + // retrieving a STRING + + // when the value is a string - returns the string + $config->updateConfig([$key => 'abc']); + self::assertSame('abc', $config->pickConfigString($key)); + + // when the value is a string with commas - returns the string + $config->updateConfig([$key => 'abc,def']); + self::assertSame('abc,def', $config->pickConfigString($key)); + + // when the value is an empty string - returns null + $config->updateConfig([$key => '']); + self::assertNull($config->pickConfigString($key)); + + // when the value is null - returns null + $config->updateConfig([$key => null]); + self::assertNull($config->pickConfigString($key)); + + // when the value is an array - returns null + $config->updateConfig([$key => ['abc' => 'def']]); + self::assertNull($config->pickConfigString($key)); + + // when the value is an empty array - returns null + $config->updateConfig([$key => []]); + self::assertNull($config->pickConfigString($key)); + + // when the value is true - returns null + $config->updateConfig([$key => true]); + self::assertNull($config->pickConfigString($key)); + + // when the value is false - returns null + $config->updateConfig([$key => false]); + self::assertNull($config->pickConfigString($key)); + + // when the value is an integer - returns null + $config->updateConfig([$key => 123]); + self::assertNull($config->pickConfigString($key)); + + // when the value is a float - returns null + $config->updateConfig([$key => 123.456]); + self::assertNull($config->pickConfigString($key)); + + + + // retrieving an ARRAY + + // when the value is a string - returns an array with the string as the value + $config->updateConfig([$key => 'abc']); + self::assertSame(['abc'], $config->pickConfigStringArray($key)); + + // when the value is a string with commas - returns an array with the string as the value + $config->updateConfig([$key => 'abc,def']); + self::assertSame(['abc,def'], $config->pickConfigStringArray($key)); + + // when the value is an empty string - returns an empty array + $config->updateConfig([$key => '']); + self::assertSame([], $config->pickConfigStringArray($key)); + + // when the value is null - returns an empty array + $config->updateConfig([$key => null]); + self::assertSame([], $config->pickConfigStringArray($key)); + + // when the value is an array - returns the array + $config->updateConfig([$key => ['abc' => 'def']]); + self::assertSame(['abc' => 'def'], $config->pickConfigStringArray($key)); + + // when the value is an empty array - returns an empty array + $config->updateConfig([$key => []]); + self::assertSame([], $config->pickConfigStringArray($key)); + + // when the value is true - returns an empty array + $config->updateConfig([$key => true]); + self::assertSame([], $config->pickConfigStringArray($key)); + + // when the value is false - returns an empty array + $config->updateConfig([$key => false]); + self::assertSame([], $config->pickConfigStringArray($key)); + + // when the value is an integer - returns an empty array + $config->updateConfig([$key => 123]); + self::assertSame([], $config->pickConfigStringArray($key)); + + // when the value is a float - returns an empty array + $config->updateConfig([$key => 123.456]); + self::assertSame([], $config->pickConfigStringArray($key)); + } + + /** + * Test the particular values that the framework config fetches. + * + * @test + * + * @return void + */ + public static function test_framework_config_settings(): void + { + $config = Framework::config(); + $config->updateConfig(['logging.default' => 'default-channel']); + + + + // getEnabled() + $config->updateConfig([InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.enabled' => null]); + self::assertNull($config->getEnabled()); // defaults to null + $config->updateConfig([InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.enabled' => true]); + self::assertTrue($config->getEnabled()); + $config->updateConfig([InternalSettings::LARAVEL_CONTEXT__CONFIG_NAME . '.enabled' => false]); + self::assertFalse($config->getEnabled()); + + + + // getChannelsWhenKnown() + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => null]); + self::assertSame([], $config->getChannelsWhenKnown()); // defaults to [] + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => []]); + self::assertSame([], $config->getChannelsWhenKnown()); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => ['abc']]); + self::assertSame(['abc'], $config->getChannelsWhenKnown()); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => 'ab,c']); + self::assertSame(['ab', 'c'], $config->getChannelsWhenKnown()); + $config->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => ['abc', 'def']] + ); + self::assertSame(['abc', 'def'], $config->getChannelsWhenKnown()); + + + + // getChannelsWhenNotKnown() + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => null]); + self::assertSame([], $config->getChannelsWhenNotKnown()); // defaults to [] + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => []]); + self::assertSame([], $config->getChannelsWhenNotKnown()); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => ['abc']]); + self::assertSame(['abc'], $config->getChannelsWhenNotKnown()); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => 'ab,c']); + self::assertSame(['ab', 'c'], $config->getChannelsWhenNotKnown()); + $config->updateConfig( + [InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => ['abc', 'def']] + ); + self::assertSame(['abc', 'def'], $config->getChannelsWhenNotKnown()); + + + + // pickBestChannels() + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => ['abc']]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => ['def']]); + self::assertSame(['abc'], $config->pickBestChannels(true)); + self::assertSame(['def'], $config->pickBestChannels(false)); + + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => ['abc']]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => []]); + self::assertSame(['abc'], $config->pickBestChannels(true)); + self::assertSame(['default-channel'], $config->pickBestChannels(false)); + + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => ['abc']]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => null]); + self::assertSame(['abc'], $config->pickBestChannels(true)); + self::assertSame(['default-channel'], $config->pickBestChannels(false)); + + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => []]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => ['def']]); + self::assertSame(['default-channel'], $config->pickBestChannels(true)); + self::assertSame(['def'], $config->pickBestChannels(false)); + + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => null]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => ['def']]); + self::assertSame(['default-channel'], $config->pickBestChannels(true)); + self::assertSame(['def'], $config->pickBestChannels(false)); + + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => []]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => []]); + self::assertSame(['default-channel'], $config->pickBestChannels(true)); + self::assertSame(['default-channel'], $config->pickBestChannels(false)); + + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_known' => null]); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.channels.when_not_known' => null]); + self::assertSame(['default-channel'], $config->pickBestChannels(true)); + self::assertSame(['default-channel'], $config->pickBestChannels(false)); + + + + // getLevelWhenKnown() + $key = InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_known'; + $config->updateConfig([$key => null]); + self::assertNull($config->getLevelWhenKnown()); + $config->updateConfig([$key => Settings::REPORTING_LEVEL_INFO]); + self::assertSame(Settings::REPORTING_LEVEL_INFO, $config->getLevelWhenKnown()); + + + + // getLevelWhenNotKnown() + $key = InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known'; + $config->updateConfig([$key => null]); + self::assertNull($config->getLevelWhenNotKnown()); + $config->updateConfig([$key => Settings::REPORTING_LEVEL_INFO]); + self::assertSame(Settings::REPORTING_LEVEL_INFO, $config->getLevelWhenNotKnown()); + + + + // pickBestLevel() + $knownKey = InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_known'; + $notKnownKey = InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.level.when_not_known'; + $config->updateConfig([$knownKey => Settings::REPORTING_LEVEL_INFO]); + $config->updateConfig([$notKnownKey => Settings::REPORTING_LEVEL_DEBUG]); + self::assertSame(Settings::REPORTING_LEVEL_INFO, $config->pickBestLevel(true)); + self::assertSame(Settings::REPORTING_LEVEL_DEBUG, $config->pickBestLevel(false)); + + $config->updateConfig([$knownKey => Settings::REPORTING_LEVEL_INFO]); + $config->updateConfig([$notKnownKey => null]); + self::assertSame(Settings::REPORTING_LEVEL_INFO, $config->pickBestLevel(true)); + self::assertSame(null, $config->pickBestLevel(false)); + + $config->updateConfig([$knownKey => Settings::REPORTING_LEVEL_INFO]); + $config->updateConfig([$notKnownKey => null]); + self::assertSame(Settings::REPORTING_LEVEL_INFO, $config->pickBestLevel(true)); + self::assertSame(null, $config->pickBestLevel(false)); + + $config->updateConfig([$knownKey => null]); + $config->updateConfig([$notKnownKey => Settings::REPORTING_LEVEL_DEBUG]); + self::assertSame(null, $config->pickBestLevel(true)); + self::assertSame(Settings::REPORTING_LEVEL_DEBUG, $config->pickBestLevel(false)); + + $config->updateConfig([$knownKey => null]); + $config->updateConfig([$notKnownKey => null]); + self::assertSame(null, $config->pickBestLevel(true)); + self::assertSame(null, $config->pickBestLevel(false)); + + $config->updateConfig([$knownKey => null]); + $config->updateConfig([$notKnownKey => null]); + self::assertSame(null, $config->pickBestLevel(true)); + self::assertSame(null, $config->pickBestLevel(false)); + + + $config->updateConfig([$knownKey => 'invalid1']); + $config->updateConfig([$notKnownKey => 'invalid2']); + + $caughtException = false; + try { + self::assertSame(null, $config->pickBestLevel(true)); + } catch (ClarityContextInitialisationException) { + $caughtException = true; + } + self::assertTrue($caughtException); + + $caughtException = false; + try { + self::assertSame(null, $config->pickBestLevel(false)); + } catch (ClarityContextInitialisationException) { + $caughtException = true; + } + self::assertTrue($caughtException); + + + + // getReport + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => null]); + self::assertNull($config->getReport()); // defaults to null + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => true]); + self::assertTrue($config->getReport()); + $config->updateConfig([InternalSettings::LARAVEL_CONTROL__CONFIG_NAME . '.report' => false]); + self::assertFalse($config->getReport()); + } +} diff --git a/tests/Unit/Support/Framework/LaravelFrameworkDepInjUnitTest.php b/tests/Unit/Support/Framework/LaravelFrameworkDepInjUnitTest.php new file mode 100644 index 0000000..e8c3101 --- /dev/null +++ b/tests/Unit/Support/Framework/LaravelFrameworkDepInjUnitTest.php @@ -0,0 +1,118 @@ + 'called-default'; + $callable2 = fn() => 'called-default2'; + + // get + $key = 'get-key'; + // value not stored yet + self::assertNull($depInjection->get($key)); + // return the default + self::assertSame('default', $depInjection->get($key, 'default')); + // the default value was not stored + self::assertNull($depInjection->get($key)); // the default value was not stored + + $key = 'get-key-with-callable'; + // called and return the default + self::assertSame('called-default', $depInjection->get($key, $callable)); + // the default value was not stored + self::assertNull($depInjection->get($key)); + + // getOrSet + $key = 'getOrSet-key'; + // set, and return the default + self::assertSame('default', $depInjection->getOrSet($key, 'default')); + // the default was stored + self::assertSame('default', $depInjection->get($key)); + // already stored + self::assertSame('default', $depInjection->getOrSet($key, 'default2')); + + $key = 'getOrSet-key-with-callable'; + // call, set, and return the default + self::assertSame('called-default', $depInjection->getOrSet($key, $callable)); + // the default was stored + self::assertSame('called-default', $depInjection->get($key)); + // already stored + self::assertSame('called-default', $depInjection->getOrSet($key, $callable2)); + + // set + $key = 'set-key'; + // set the value + $depInjection->set($key, 'default'); + // already stored + self::assertSame('default', $depInjection->get($key)); + // already stored + self::assertSame('default', $depInjection->getOrSet($key, 'default2')); + + $key = 'set-key-with-callable'; + // set the value - the callable is not run + $depInjection->set($key, $callable); + // already stored + self::assertSame($callable, $depInjection->get($key)); + + + + // make + $valueObject1 = $depInjection->make(ValueObject::class); + self::assertInstanceOf(ValueObject::class, $valueObject1); + self::assertSame(null, $valueObject1->getValue()); + + $valueObject2 = $depInjection->make(ValueObject::class, ['value' => 'a']); + self::assertInstanceOf(ValueObject::class, $valueObject2); + self::assertSame('a', $valueObject2->getValue()); + + self::assertNotSame($valueObject1, $valueObject2); + + + + // call + // the Support class is used here, but it doesn't matter which class is used + $callableRan = false; + $callable = function (Support $catchType, string $blah) use (&$callableRan) { + self::assertInstanceOf(Support::class, $catchType); + self::assertSame('hello', $blah); + $callableRan = true; + }; + $depInjection->call($callable, ['blah' => 'hello']); + self::assertTrue($callableRan); + } +} diff --git a/tests/Unit/Support/MetaCallStackUnitTest.php b/tests/Unit/Support/MetaCallStackUnitTest.php new file mode 100644 index 0000000..506acc2 --- /dev/null +++ b/tests/Unit/Support/MetaCallStackUnitTest.php @@ -0,0 +1,733 @@ +getStackMetaData() method. + * + * @test + * + * @return void + */ + public static function test_storage_of_meta_data_x_steps_back(): void + { + // start empty + $metaCallStack = new MetaCallStack(); + self::assertSame([], $metaCallStack->getStackMetaData()); + + + + // go back x steps, and check what was stored each time + $currentFrameIndex = PHPStackTraceHelper::getCurrentFrameIndex(); + for ($count = -1; $count <= $currentFrameIndex + 1; $count++) { + + $expectException = ($count == -1 || $count == $currentFrameIndex + 1); + + $caughtException = false; + try { + + $metaCallStack->pushMultipleMetaDataValues("type$count", null, ["value$count"], $count); + + $metaData = $metaCallStack->getStackMetaData(); + self::assertSame([$currentFrameIndex - $count], array_keys($metaData)); + + self::assertSame("type$count", $metaData[$currentFrameIndex - $count][0]['type']); + self::assertSame("value$count", $metaData[$currentFrameIndex - $count][0]['value']); + + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertSame($expectException, $caughtException); + } + } + + + + /** + * Test that MetaCallStack can record different sorts of meta-data. + * + * @test + * + * @return void + */ + public static function test_storage_of_different_sorts_of_meta_data(): void + { + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA'], 0); + $metaCallStack->pushMultipleMetaDataValues('typeB', null, ['valueB'], 0); + + $currentFrameIndex = PHPStackTraceHelper::getCurrentFrameIndex(); + + $metaData = $metaCallStack->getStackMetaData(); + self::assertSame([$currentFrameIndex], array_keys($metaData)); + self::assertSame([0, 1], array_keys($metaData[$currentFrameIndex])); + self::assertSame('typeA', $metaData[$currentFrameIndex][0]['type']); + self::assertSame('valueA', $metaData[$currentFrameIndex][0]['value']); + self::assertSame('typeB', $metaData[$currentFrameIndex][1]['type']); + self::assertSame('valueB', $metaData[$currentFrameIndex][1]['value']); + } + + + + /** + * Test that MetaCallStack can update a meta-data's value. + * + * @test + * + * @return void + */ + public static function test_replacement_of_a_meta_datas_value(): void + { + $metaCallStack = new MetaCallStack(); + + + + // set the "typeA" meta-data up initially + $metaCallStack->pushMultipleMetaDataValues('typeA', 123, [['fieldA' => 'a', 'fieldB' => 'b']], 0); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame(['fieldA' => 'a', 'fieldB' => 'b'], $metaData[$lastIndex][0]['value']); + + + + // try to update the identifier 456 (which DOESN'T exist) meta-data's value + $metaCallStack->replaceMetaDataValue('typeXXX', 456, ['fieldA' => 'A', 'fieldB' => 'b']); + $metaData = $metaCallStack->getStackMetaData(); + + // check that there's NO change + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame(['fieldA' => 'a', 'fieldB' => 'b'], $metaData[$lastIndex][0]['value']); + + + + // update the typeA meta-data's value by searching for identifier 'XXX', which DOESN'T exist + $metaCallStack->replaceMetaDataValue('typeA', 'XXX', ['fieldA' => 'A', 'fieldB' => 'b']); + $metaData = $metaCallStack->getStackMetaData(); + + // check that there's NO change + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame(['fieldA' => 'a', 'fieldB' => 'b'], $metaData[$lastIndex][0]['value']); + + + + // update the typeXXX meta-data's value by searching for identifier 123, which DOESN'T exist + // (everything matches, but the type is wrong) + $metaCallStack->replaceMetaDataValue('typeXXX', 123, ['fieldA' => 'A', 'fieldB' => 'b']); + $metaData = $metaCallStack->getStackMetaData(); + + // check that there's NO change + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame(['fieldA' => 'a', 'fieldB' => 'b'], $metaData[$lastIndex][0]['value']); + + + + // update the typeA meta-data's value by searching for identifier 123, which DOES exist + $metaCallStack->replaceMetaDataValue('typeA', 123, ['fieldA' => 'A', 'fieldB' => 'b']); + $metaData = $metaCallStack->getStackMetaData(); + + // check that there IS a change + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame(['fieldA' => 'A', 'fieldB' => 'b'], $metaData[$lastIndex][0]['value']); + } + + + + /** + * Test that the meta-data is pruned when adding meta-data. + * + * Test adding meta-data of different types. + * + * Test replacing meta-data with $removeOthers = true. + * + * @test + * + * @return void + */ + public static function test_pruning_when_adding_meta_data(): void + { + $metaCallStack = new MetaCallStack(); + + // add the first add two meta-data, with $removeOthers = false + $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA1'], 0); + $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA2'], 0); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertCount(2, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA1', $metaData[$lastIndex][0]['value']); + self::assertSame('typeA', $metaData[$lastIndex][1]['type']); + self::assertSame('valueA2', $metaData[$lastIndex][1]['value']); + + + + // add new meta-data to the next frame, with $removeOthers = true (won't remove anything) + $a = fn() => $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA3'], 0, ['typeA']); + $a(); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + // same as before on this frame + self::assertCount(2, $metaData[$lastIndex - 1] ?? []); + self::assertSame('typeA', $metaData[$lastIndex - 1][0]['type']); + self::assertSame('valueA1', $metaData[$lastIndex - 1][0]['value']); + self::assertSame('typeA', $metaData[$lastIndex - 1][1]['type']); + self::assertSame('valueA2', $metaData[$lastIndex - 1][1]['value']); + + // but with new meta-data on this frame + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA3', $metaData[$lastIndex][0]['value']); + + + + // add new meta-data, with $removeOthers = true (back to this frame this time, will remove others) + $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA3'], 0, ['typeA']); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + // only this new meta-data on this frame + self::assertCount(1, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA3', $metaData[$lastIndex][0]['value']); + + + + // add a different type, with $removeOthers = true (which won't remove anything) + $metaCallStack->pushMultipleMetaDataValues('typeB', null, ['valueB1'], 0, ['typeB']); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + // both meta-data was added + self::assertCount(2, $metaData[$lastIndex] ?? []); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA3', $metaData[$lastIndex][0]['value']); + self::assertSame('typeB', $metaData[$lastIndex][1]['type']); + self::assertSame('valueB1', $metaData[$lastIndex][1]['value']); + } + + + + /** + * Test that the meta-data is pruned based on a stack trace. + * + * @test + * + * @return void + */ + public static function test_pruning_based_on_a_stack_trace(): void + { + // add some meta-data in this frame + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA'], 0); + + + + // add some meta-data inside a closure (i.e. in the next frame from this one) + $a = function (MetaCallStack $metaCallStack, string $type, string $value) { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + }; + $a($metaCallStack, 'typeB', 'valueB'); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex1 = max(array_keys($metaData)); + + + + // now prune off the last frame + $phpStackTrace = PHPStackTraceHelper::buildPHPStackTraceHere(); + $preparedStackTrace = Support::preparePHPStackTrace($phpStackTrace); + $callstack = array_reverse($preparedStackTrace); + $metaCallStack->pruneBasedOnRegularCallStack($callstack); + + // check that valueB was pruned off + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex2 = max(array_keys($metaData)); + + self::assertSame($lastIndex1 - 1, $lastIndex2); + self::assertCount(1, $metaData[$lastIndex2]); + self::assertSame('typeA', $metaData[$lastIndex2][0]['type']); + self::assertSame('valueA', $metaData[$lastIndex2][0]['value']); + } + + + + /** + * Test that the meta-data is pruned based on an exception. + * + * @test + * + * @return void + */ + public static function test_pruning_based_on_an_exception(): void + { + // build a new MetaCallStack instance - also add some initial meta-data in the previous frame + $newMetaCallStack = function (): MetaCallStack { + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeA', null, ['valueA'], 1); + return $metaCallStack; + }; + // add some meta-data inside a closure (i.e. in the next frame from this one) + $pushMetaData = function (MetaCallStack $metaCallStack, string $type, string $value) { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + }; + // generate an exception in a later frame + $generateException = function (): Exception { + return new Exception(); + }; + + + + // exception thrown in an EARLIER FRAME than valueB meta-data was added + // call from the SAME LINE + $metaCallStack = $newMetaCallStack(); + $pushMetaData($metaCallStack, 'typeB', 'valueB'); $e = new Exception(); // phpcs:ignore + self::pruneWithExceptionAndCheck($metaCallStack, $e, false, false); + + // call from DIFFERENT LINES + $metaCallStack = $newMetaCallStack(); + $pushMetaData($metaCallStack, 'typeB', 'valueB'); + $e = new Exception(); + self::pruneWithExceptionAndCheck($metaCallStack, $e, false, false); + + + + // exception thrown in THE SAME FRAME as valueB meta-data was added + // call from the SAME LINE + $metaCallStack = $newMetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeB', null, ['valueB'], 0); $e = new Exception(); // phpcs:ignore + self::pruneWithExceptionAndCheck($metaCallStack, $e, true, true); + + // call from DIFFERENT LINES + $metaCallStack = $newMetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeB', null, ['valueB'], 0); + $e = new Exception(); + self::pruneWithExceptionAndCheck($metaCallStack, $e, true, true); + + + + // exception thrown in A DIFFERENT FRAME as valueB meta-data was added, but at the SAME DEPTH + // call from the SAME LINE + // NOTE: this one calls two different closures on the same line, and suffers the same problem as test + // test_meta_data_pruning_when_calling_different_closures_on_the_same_line(), where Clarity can't tell the + // difference between the closure's frames, and thinks they're the same + $metaCallStack = $newMetaCallStack(); + $pushMetaData($metaCallStack, 'typeB', 'valueB'); $e = $generateException(); // phpcs:ignore + self::pruneWithExceptionAndCheck($metaCallStack, $e, true, false); + + // call from DIFFERENT LINES + $metaCallStack = $newMetaCallStack(); + $pushMetaData($metaCallStack, 'typeB', 'valueB'); + $e = $generateException(); + self::pruneWithExceptionAndCheck($metaCallStack, $e, false, false); + + // call from the SAME LINE - but generate the exception from another class + $metaCallStack = $newMetaCallStack(); + $pushMetaData($metaCallStack, 'typeB', 'valueB'); $e = SomeOtherClass::generateException(); // phpcs:ignore + self::pruneWithExceptionAndCheck($metaCallStack, $e, false, false); + + + + // exception thrown in a LATER FRAME than valueB meta-data was added + // call from the SAME LINE + $metaCallStack = $newMetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeB', null, ['valueB'], 0); $e = $generateException(); // phpcs:ignore + self::pruneWithExceptionAndCheck($metaCallStack, $e, true, true); + + // call from DIFFERENT LINES + $metaCallStack = $newMetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeB', null, ['valueB'], 0); + $e = $generateException(); + self::pruneWithExceptionAndCheck($metaCallStack, $e, true, true); + } + + + + /** + * Prune a MetaCallStack based on an an Exception's stack trace, and check the results. + * + * @param MetaCallStack $metaCallStack The MetaCallStack to check. + * @param Exception $e The exception to use. + * @param boolean $shouldValueBExist Should the "valueB" meta-data exist after pruning?. + * @param boolean $sameFrameAsValueA If so, should it exist in the same frame as the "valueA" meta-data, or + * the next?. + * @return void + */ + private static function pruneWithExceptionAndCheck( + MetaCallStack $metaCallStack, + Exception $e, + bool $shouldValueBExist, + bool $sameFrameAsValueA + ): void { + + // prune off the based on the exception + $preparedStackTrace = Support::preparePHPStackTrace($e->getTrace(), $e->getFile(), $e->getLine()); + $callstack = array_reverse($preparedStackTrace); + $metaCallStack->pruneBasedOnExceptionCallStack($callstack); + + $metaData = $metaCallStack->getStackMetaData(); + + $valueAFrameIndex = min(array_keys($metaData)); + $lastFrameIndex = max(array_keys($metaData)); + + if ($shouldValueBExist) { + if ($sameFrameAsValueA) { + self::assertSame($valueAFrameIndex, $lastFrameIndex); + + self::assertSame(2, count($metaData[$valueAFrameIndex])); + self::assertSame('typeA', $metaData[$valueAFrameIndex][0]['type']); + self::assertSame('valueA', $metaData[$valueAFrameIndex][0]['value']); + self::assertSame('typeB', $metaData[$valueAFrameIndex][1]['type']); + self::assertSame('valueB', $metaData[$valueAFrameIndex][1]['value']); + } else { + self::assertSame($valueAFrameIndex + 1, $lastFrameIndex); + + self::assertSame(1, count($metaData[$valueAFrameIndex])); + self::assertSame('typeA', $metaData[$valueAFrameIndex][0]['type']); + self::assertSame('valueA', $metaData[$valueAFrameIndex][0]['value']); + + self::assertSame(1, count($metaData[$lastFrameIndex])); + self::assertSame('typeB', $metaData[$lastFrameIndex][0]['type']); + self::assertSame('valueB', $metaData[$lastFrameIndex][0]['value']); + } + } else { + self::assertSame($valueAFrameIndex, $lastFrameIndex); + + self::assertSame(1, count($metaData[$valueAFrameIndex])); + self::assertSame('typeA', $metaData[$valueAFrameIndex][0]['type']); + self::assertSame('valueA', $metaData[$valueAFrameIndex][0]['value']); + } + } + + + + + + /** + * Test that meta-data gets pruned properly: when meta-data is added via THE SAME CLOSURE, called twice from the + * same line. + * + * NOTE: Because of the data that PHP's debug_backtrace() provides, calls *occurring on the same line* to closure/s + * make it look like the inside of closure/s are in the same frame. The meta-data won't be pruned. + * + * This test just confirms this "known" behaviour, even though it is incorrect. + * + * @test + * + * @return void + */ + public static function test_meta_data_pruning_when_calling_the_same_closure_on_the_same_line(): void + { + $metaCallStack = new MetaCallStack(); + + + + // add some meta-data inside a closure (i.e. in the next frame from this one) + $a = function (MetaCallStack $metaCallStack, string $type, string $value) { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + }; + + // unfortunately, PHP doesn't let us see the difference between the two closure calls using debug_stacktrace() + // so Clarity considers the two calls to ->pushMultipleMetaData(..) (in the closure above) to be in the same + // frame. Both of the meta-data values added above will be recorded, and present below + $a($metaCallStack, 'typeA', 'valueA'); $a($metaCallStack, 'typeB', 'valueB'); // phpcs:ignore + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(2, count($metaData[$lastIndex])); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA', $metaData[$lastIndex][0]['value']); + self::assertSame('typeB', $metaData[$lastIndex][1]['type']); + self::assertSame('valueB', $metaData[$lastIndex][1]['value']); + + + + // when the closure calls are on DIFFERENT LINES, Clarity CAN tell the difference between them, and will + // prune the meta-data properly + $a($metaCallStack, 'typeA', 'valueA'); + $a($metaCallStack, 'typeB', 'valueB'); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(1, count($metaData[$lastIndex])); + self::assertSame('typeB', $metaData[$lastIndex][0]['type']); + self::assertSame('valueB', $metaData[$lastIndex][0]['value']); + } + + /** + * Test that meta-data gets pruned properly: when meta-data is added via TWO DIFFERENT CLOSURES, called from the + * same line. + * + * NOTE: Because of the data that PHP's debug_backtrace() provides, calls *occurring on the same line* to closure/s + * make it look like the inside of closure/s are in the same frame. The meta-data won't be pruned. + * + * This test just confirms this "known" behaviour, even though it is incorrect. + * + * @test + * + * @return void + */ + public static function test_meta_data_pruning_when_calling_different_closures_on_the_same_line(): void + { + $metaCallStack = new MetaCallStack(); + + + + // add some meta-data inside a closure (i.e. in the next frame from this one) + $a = function (MetaCallStack $metaCallStack, string $type, string $value) { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + }; + + // add some meta-data inside a closure (i.e. in the next frame from this one) + $b = function (MetaCallStack $metaCallStack, string $type, string $value) { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + }; + + // unfortunately, PHP doesn't let us see the difference between the two closures using debug_stacktrace() + // so Clarity considers the two calls to ->pushMultipleMetaData(..) (in the closures above) to be in the same + // frame. Both of the meta-data values added above will be recorded, and present below + $a($metaCallStack, 'typeA', 'valueA'); $b($metaCallStack, 'typeB', 'valueB'); // phpcs:ignore + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(2, count($metaData[$lastIndex])); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA', $metaData[$lastIndex][0]['value']); + self::assertSame('typeB', $metaData[$lastIndex][1]['type']); + self::assertSame('valueB', $metaData[$lastIndex][1]['value']); + + + + // when the closure calls are on DIFFERENT LINES, Clarity CAN tell the difference between them, and will + // prune the meta-data properly + $a($metaCallStack, 'typeA', 'valueA'); + $b($metaCallStack, 'typeB', 'valueB'); + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(1, count($metaData[$lastIndex])); + self::assertSame('typeB', $metaData[$lastIndex][0]['type']); + self::assertSame('valueB', $metaData[$lastIndex][0]['value']); + } + + /** + * Test that meta-data gets pruned properly: when meta-data is added via THE SAME STATIC METHOD, called twice from + * the same line. + * + * NOTE: Because of the data that PHP's debug_backtrace() provides, calls *occurring on the same line* to a static + * method make it look like the inside of the static method is in the same frame for both calls. The meta-data won't + * be pruned. + * + * This test just confirms this "known" behaviour, even though it is incorrect. + * + * @test + * + * @return void + */ + public static function test_meta_data_pruning_when_calling_the_same_static_method_on_the_same_line(): void + { + $metaCallStack = new MetaCallStack(); + + + + // unfortunately, PHP doesn't let us see the difference between the two static method calls using + // debug_stacktrace(), so Clarity considers the two calls to ->pushMultipleMetaData(..) (inside the static + // method) to be in the same frame. Both of the meta-data values added above will be recorded, and present below + self::addMetaDataA($metaCallStack, 'typeA', 'valueA'); self::addMetaDataA($metaCallStack, 'typeB', 'valueB'); // phpcs:ignore + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(2, count($metaData[$lastIndex])); + self::assertSame('typeA', $metaData[$lastIndex][0]['type']); + self::assertSame('valueA', $metaData[$lastIndex][0]['value']); + self::assertSame('typeB', $metaData[$lastIndex][1]['type']); + self::assertSame('valueB', $metaData[$lastIndex][1]['value']); + + + + // when the static method calls are on DIFFERENT LINES, Clarity CAN tell the difference between them, and + // will prune the meta-data properly + self::addMetaDataA($metaCallStack, 'typeA', 'valueA'); + self::addMetaDataA($metaCallStack, 'typeB', 'valueB'); // phpcs:ignore + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(1, count($metaData[$lastIndex])); + self::assertSame('typeB', $metaData[$lastIndex][0]['type']); + self::assertSame('valueB', $metaData[$lastIndex][0]['value']); + } + + /** + * Test that meta-data gets pruned properly: when meta-data is added via TWO DIFFERENT STATIC METHODS, called from + * the same line. + * + * @test + * + * @return void + */ + public static function test_meta_data_pruning_when_calling_different_static_methods_on_the_same_line(): void + { + $metaCallStack = new MetaCallStack(); + + + + // when the static method calls are on the *same line*, Clarity CAN tell the difference between them, and will + // prune the meta-data properly + self::addMetaDataA($metaCallStack, 'typeA', 'valueA'); self::addMetaDataB($metaCallStack, 'typeB', 'valueB'); // phpcs:ignore + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(1, count($metaData[$lastIndex])); + self::assertSame('typeB', $metaData[$lastIndex][0]['type']); + self::assertSame('valueB', $metaData[$lastIndex][0]['value']); + + + + // when the static method calls are on DIFFERENT LINES, Clarity CAN tell the difference between them, and + // will prune the meta-data properly + self::addMetaDataA($metaCallStack, 'typeA', 'valueA'); + self::addMetaDataB($metaCallStack, 'typeB', 'valueB'); // phpcs:ignore + + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(1, count($metaData[$lastIndex])); + self::assertSame('typeB', $metaData[$lastIndex][0]['type']); + self::assertSame('valueB', $metaData[$lastIndex][0]['value']); + } + + /** + * Add some meta-data to a MetaCallStack for the caller. + * + * @param MetaCallStack $metaCallStack The MetaCallStack to add meta-data to. + * @param string $type The "type" of meta-data to add. + * @param string $value The meta-data value to add. + * @return void + */ + private static function addMetaDataA(MetaCallStack $metaCallStack, string $type, string $value): void + { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + } + + /** + * Add some meta-data to a MetaCallStack for the caller (this one is here just because it's a different method to + * the on above). + * + * @param MetaCallStack $metaCallStack The MetaCallStack to add meta-data to. + * @param string $type The "type" of meta-data to add. + * @param string $value The meta-data value to add. + * @return void + */ + private static function addMetaDataB(MetaCallStack $metaCallStack, string $type, string $value): void + { + $metaCallStack->pushMultipleMetaDataValues($type, null, [$value], 0); + } + + + + + + /** + * Test the addition of multiple meta-data values from the same call. + * + * @test + * + * @return void + */ + public static function test_the_addition_of_multiple_meta_data_from_the_same_call(): void + { + // add some meta-data to start with + $metaCallStack = new MetaCallStack(); + $metaCallStack->pushMultipleMetaDataValues('typeZ', null, ['valueZ'], 0); + + + + // closure to add multiple meta-data + $a = function (string $type, array $multipleMetaData) use (&$metaCallStack) { + $metaCallStack->pushMultipleMetaDataValues($type, null, $multipleMetaData, 1); + }; + + // closure to count how many meta-data objects are currently stored, and record the result + $countsPerIteration = []; + $addCount = function () use (&$metaCallStack, &$countsPerIteration) { + $metaData = $metaCallStack->getStackMetaData(); + if (!count($metaData)) { + $countsPerIteration[] = 0; + return; + } + $lastIndex = max(array_keys($metaData)); + $countsPerIteration[] = count($metaData[$lastIndex]); + }; + + + + // loop twice, so meta-data is added on the same line for a second time + $possibleValues = [ + 'a' => ['valueA1a', 'valueA2a'], + 'b' => ['valueA1b'], + 'c' => ['valueA1c', 'valueA2c', 'valueA3c'], + ]; + $addCount(); + foreach (['a', 'b', 'c'] as $i) { + $a('typeA', $possibleValues[$i]); $addCount(); // phpcs:ignore + $a('typeB', ["valueB1$i"]); $addCount(); // phpcs:ignore + } + + + + // check the meta-data counts for each iteration + self::assertSame([1, 3, 4, 3, 3, 5, 5], $countsPerIteration); + + // check the meta-data that's left + $metaData = $metaCallStack->getStackMetaData(); + $lastIndex = max(array_keys($metaData)); + + self::assertSame(5, count($metaData[$lastIndex])); + self::assertSame('typeZ', $metaData[$lastIndex][0]['type']); // the initial meta-data + self::assertSame('valueZ', $metaData[$lastIndex][0]['value']); + self::assertSame('typeA', $metaData[$lastIndex][1]['type']); + self::assertSame('valueA1c', $metaData[$lastIndex][1]['value']); + self::assertSame('typeA', $metaData[$lastIndex][2]['type']); + self::assertSame('valueA2c', $metaData[$lastIndex][2]['value']); + self::assertSame('typeA', $metaData[$lastIndex][3]['type']); + self::assertSame('valueA3c', $metaData[$lastIndex][3]['value']); + self::assertSame('typeB', $metaData[$lastIndex][4]['type']); + self::assertSame('valueB1c', $metaData[$lastIndex][4]['value']); + } +} diff --git a/tests/Unit/Support/SupportUnitTest.php b/tests/Unit/Support/SupportUnitTest.php new file mode 100644 index 0000000..8ca6bb2 --- /dev/null +++ b/tests/Unit/Support/SupportUnitTest.php @@ -0,0 +1,561 @@ + $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, + ]; + } + + + + /** + * Test Support::resolveProjectFile to see that it generates the correct project file. + * + * @test + * @dataProvider resolveProjectFileDataProvider + * + * @param string $expected The expected "project file". + * @param string $file The input file. + * @param string $projectRootDir The project root dir. + * @return void + */ + public static function test_resolve_project_file_method( + string $expected, + string $file, + string $projectRootDir + ): void { + + $expected = str_replace('/', DIRECTORY_SEPARATOR, $expected); + $file = str_replace('/', DIRECTORY_SEPARATOR, $file); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $projectRootDir); + + self::assertSame($expected, Support::resolveProjectFile($file, $projectRootDir)); + } + + /** + * DataProvider for test_resolve_project_file(). + * + * @return array> + */ + public static function resolveProjectFileDataProvider(): array + { + $return = []; + + // no projectRootDir - the root couldn't be resolved, so it's impossible to pick the project-path + $return[] = [ + 'expected' => '/path/to/root/src/file.php', + 'file' => '/path/to/root/src/file.php', + 'projectRootDir' => '' + ]; + $return[] = [ + 'expected' => '/path/to/root-other/src/file.php', + 'file' => '/path/to/root-other/src/file.php', + 'projectRootDir' => '' + ]; + $return[] = [ + 'expected' => '/not/path/to/root/src/file.php', + 'file' => '/not/path/to/root/src/file.php', + 'projectRootDir' => '' + ]; + + // with projectRootDir - the root was resolved, so the project-path can be picked + $return[] = [ + 'expected' => '/src/file.php', + 'file' => '/path/to/root/src/file.php', + 'projectRootDir' => '/path/to/root' + ]; + $return[] = [ + 'expected' => '/path/to/root-other/src/file.php', + 'file' => '/path/to/root-other/src/file.php', + 'projectRootDir' => '/path/to/root' + ]; + $return[] = [ + 'expected' => '/not/path/to/root/src/file.php', + 'file' => '/not/path/to/root/src/file.php', + 'projectRootDir' => '/path/to/root' + ]; + + // with projectRootDir - that has a trailing "/" + $return[] = [ + 'expected' => '/src/file.php', + 'file' => '/path/to/root/src/file.php', + 'projectRootDir' => '/path/to/root/' + ]; + $return[] = [ + 'expected' => '/path/to/root-other/src/file.php', + 'file' => '/path/to/root-other/src/file.php', + 'projectRootDir' => '/path/to/root/' + ]; + $return[] = [ + 'expected' => '/not/path/to/root/src/file.php', + 'file' => '/not/path/to/root/src/file.php', + 'projectRootDir' => '/path/to/root/' + ]; + + // with non-ascii characters + $return[] = [ + 'expected' => '/src/file.php', + 'file' => '/path/to/ro☺️ot/src/file.php', + 'projectRootDir' => '/path/to/ro☺️ot/' + ]; + $return[] = [ + 'expected' => '/src/fi☺️le.php', + 'file' => '/path/to/root/src/fi☺️le.php', + 'projectRootDir' => '/path/to/root/' + ]; + + return $return; + } + + + + /** + * Test Support::isApplicationFile to see that it checks if a project file is an application file correctly. + * + * @test + * @dataProvider resolveIsApplicationFileCheckDataProvider + * + * @param boolean $expected The expected outcome. + * @param string $projectFile The expected "project file". + * @param string $projectRootDir The project root dir. + * @return void + */ + public static function test_is_application_file_check_method( + bool $expected, + string $projectFile, + string $projectRootDir + ): void { + + $projectFile = str_replace('/', DIRECTORY_SEPARATOR, $projectFile); + $projectRootDir = str_replace('/', DIRECTORY_SEPARATOR, $projectRootDir); + + self::assertSame($expected, Support::isApplicationFile($projectFile, $projectRootDir)); + } + + /** + * DataProvider for test_resolve_project_file(). + * + * @return array> + */ + public static function resolveIsApplicationFileCheckDataProvider(): array + { + $return = []; + + // no projectRootDir - the root couldn't be resolved, so it's impossible to tell if it's a vendor file + $return[] = [ + 'expected' => true, + 'projectFile' => '/src/file.php', + 'projectRootDir' => '', + ]; + $return[] = [ + 'expected' => true, + 'projectFile' => '/vendor/file.php', + 'projectRootDir' => '', + ]; + $return[] = [ + 'expected' => true, + 'projectFile' => 'README.md', + 'projectRootDir' => '', + ]; + + // with projectRootDir - the root was resolved, so the check can proceed + $return[] = [ + 'expected' => true, + 'projectFile' => '/src/file.php', + 'projectRootDir' => '/path/to/root/', + ]; + $return[] = [ + 'expected' => false, + 'projectFile' => '/vendor/file.php', + 'projectRootDir' => '/path/to/root/', + ]; + $return[] = [ + 'expected' => true, + 'projectFile' => 'README.md', + 'projectRootDir' => '/path/to/root/', + ]; + + return $return; + } + + + + /** + * Test decideIfMetaCountsAreWorthListing() to see that it decides if the Meta counts are worth listing. + * + * @test + * @dataProvider metaTypeCountDataProvider + * + * @param array $metaTypeCounts The counts of the Meta objects. + * @param boolean $expected The expected outcome. + * @return void + */ + public static function test_decide_if_meta_counts_are_worth_listing_method( + array $metaTypeCounts, + bool $expected + ): void { + + self::assertSame($expected, Support::decideIfMetaCountsAreWorthListing($metaTypeCounts)); + } + + /** + * DataProvider for test_decide_if_meta_counts_are_worth_listing_method(). + * + * @return array|boolean>> + */ + public static function metaTypeCountDataProvider(): array + { + $return = []; + + $return[] = [[], false]; + $return[] = [[ContextMeta::class => 1], true]; + $return[] = [[LastApplicationFrameMeta::class => 1], false]; + $return[] = [[ExceptionThrownMeta::class => 1], false]; + $return[] = [[ExceptionThrownMeta::class => 1, LastApplicationFrameMeta::class => 1], false]; + $return[] = [ + [ExceptionThrownMeta::class => 1, LastApplicationFrameMeta::class => 1, ContextMeta::class => 1], + true + ]; + + return $return; + } + + + + /** + * Test that the global meta call stack can be fetched, and is the same instance each time. + * + * @test + * + * @return void + */ + public static function test_the_get_global_meta_call_stack_method(): void + { + $metaCallStack1 = Support::getGlobalMetaCallStack(); + $metaCallStack2 = Support::getGlobalMetaCallStack(); + + self::assertInstanceOf(MetaCallStack::class, $metaCallStack1); + self::assertSame($metaCallStack1, $metaCallStack2); + } + + + + /** + * Test the method that removes frames from a stack trace. + * + * @test + * + * @return void + */ + public static function test_step_back_stack_trace_method(): void + { + $phpStackTrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS); + + // remove no frames + $newStackTrace = Support::stepBackStackTrace($phpStackTrace, 0); + self::assertSame($phpStackTrace, $newStackTrace); + + // remove 1 frame + $newStackTrace = Support::stepBackStackTrace($phpStackTrace, 1); + $tempStackTrace = $phpStackTrace; + array_shift($tempStackTrace); + self::assertSame($tempStackTrace, $newStackTrace); + + // remove 2 frames + $newStackTrace = Support::stepBackStackTrace($phpStackTrace, 2); + $tempStackTrace = $phpStackTrace; + array_shift($tempStackTrace); + array_shift($tempStackTrace); + self::assertSame($tempStackTrace, $newStackTrace); + + // remove too many frames + $caughtException = false; + try { + // generate an exception + Support::stepBackStackTrace($phpStackTrace, count($phpStackTrace)); + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertTrue($caughtException); + + // test an invalid number of steps to go back + $caughtException = false; + try { + // generate an exception + Support::stepBackStackTrace($phpStackTrace, -1); + } catch (ClarityContextRuntimeException) { + $caughtException = true; + } + self::assertTrue($caughtException); + } + + + + /** + * Test the method that prepares a stack trace. + * + * @test + * + * @return void + */ + public static function test_prepare_stack_trace_method(): void + { + $phpStackTrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS); + $preparedStackTrace = Support::preparePHPStackTrace($phpStackTrace); + + + + // check the function of the earliest frame + $lastKey = array_key_last($preparedStackTrace); + self::assertSame('[top]', $preparedStackTrace[$lastKey]['function']); + + + + // test that at least the files and lines are correct + // test that the functions are shifted by one frame + + // build a representation of the frames based on PHP's stack trace + $phpCallstackFrames = []; + $function = '[top]'; + foreach (array_reverse($phpStackTrace) as $frame) { + $phpCallstackFrames[] = [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'function' => $function, + ]; + $function = $frame['function']; // shift the function by 1 frame + } + $phpStackTraceFrames = array_reverse($phpCallstackFrames); + + // build a representation of the frames based on the prepared stack trace + $preparedStackTraceFrames = []; + foreach ($preparedStackTrace as $frame) { + $preparedStackTraceFrames[] = [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'function' => $frame['function'] ?? null, + ]; + } + + self::assertSame($phpStackTraceFrames, $preparedStackTraceFrames); + + + + // check that the 'object' field in each frame has been turned into its spl_object_id (i.e. an integer) + foreach ($preparedStackTrace as $frameData) { + self::assertTrue(is_int($frameData['object'] ?? -1)); + } + + + + // check that the extra frame added by call_user_func_array() (that's missing its file and line) is removed + $pretendStackTrace = [ + [ + 'file' => '', // <<< + 'line' => 0, // <<< + ], + [ + 'file' => 'file2', + 'line' => 124, + ], + [ + 'file' => 'file1', + 'line' => 123, + ], + ]; + $preparedStackTrace = Support::preparePHPStackTrace($pretendStackTrace); + self::assertCount(2, $preparedStackTrace); + self::assertSame('file2', $preparedStackTrace[0]['file']); + self::assertSame(124, $preparedStackTrace[0]['line']); + } + + + + + + /** + * Test that Laravel exception handler frames can be removed. + * + * @test + * @dataProvider exceptionHandlerFramesDataProvider + * + * @param integer $startFrameCount The number of frames to start with. + * @param integer $addFrames The number of frames to add. + * @return void + */ + public static function test_that_laravel_exception_handler_frames_are_pruned(int $startFrameCount, int $addFrames) + { + $phpStackTrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS); + $stackTrace = Support::preparePHPStackTrace($phpStackTrace, __FILE__, __LINE__); + $stackTrace = array_slice($stackTrace, 0, $startFrameCount); + $stackTrace = self::addLaravelExceptionHandlerFrames($stackTrace, $addFrames); + + $origCount = count($stackTrace); + $stackTrace = Support::pruneLaravelExceptionHandlerFrames($stackTrace); + + self::assertCount($origCount - $addFrames, $stackTrace); + } + + /** + * DataProvider for test_that_laravel_exception_handler_frames_are_pruned(). + * + * @return array> + */ + public static function exceptionHandlerFramesDataProvider(): array + { + return [ + ['startFrameCount' => 5, 'addFrames' => 0], + ['startFrameCount' => 5, 'addFrames' => 1], + ['startFrameCount' => 5, 'addFrames' => 2], + + ['startFrameCount' => 1, 'addFrames' => 0], + ['startFrameCount' => 1, 'addFrames' => 1], + ['startFrameCount' => 1, 'addFrames' => 2], + + ['startFrameCount' => 0, 'addFrames' => 0], + ['startFrameCount' => 0, 'addFrames' => 1], + ['startFrameCount' => 0, 'addFrames' => 2], + ]; + } + + /** + * Add some Laravel exception handler frames to a stack trace. + * + * @param array $stackTrace The stack trace to add the frames to. + * @param integer $addFrames The number of frames to add. + * @return array + */ + private static function addLaravelExceptionHandlerFrames(array $stackTrace, int $addFrames): array + { + $newFrames = [ + [ + 'file' => '/var/www/html/vendor/' + . 'laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php', + 'line' => 254, + 'function' => 'Illuminate\Foundation\Bootstrap\{closure}', + 'class' => 'Illuminate\Foundation\Bootstrap\HandleExceptions', + 'type' => '->', + ], + [ + 'file' => '/var/www/html/routes/web.php', + 'line' => 51, + 'function' => 'handleError', + 'class' => 'Illuminate\Foundation\Bootstrap\HandleExceptions', + 'type' => '->', + ], + ]; + + $newFrames = array_slice($newFrames, 0, $addFrames); + + return array_merge($newFrames, $stackTrace); + } +}