diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6b45e5d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,47 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.js] +indent_size = 2 + +[*.scss] +indent_size = 2 + +[*.css] +indent_size = 2 + +[package.json] +indent_size = 2 + +[package-lock.json] +indent_size = 2 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[*.php] +ij_php_space_before_short_closure_left_parenthesis = false +ij_php_space_after_type_cast = true + +[*.yaml] +indent_size = 2 + +[*.yml] +indent_size = 2 + +[*.xml.dist] +indent_size = 2 + +[LICENSE*] +indent_style = unset +indent_size = unset diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2360366 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,43 @@ +# Autodetect text files +* text=auto eol=lf + +# ...Unless the name matches the following overriding patterns + +# Definitively text files +*.php text +*.css text +*.js text +*.txt text +*.md text +*.xml text +*.json text +*.bat text +*.sql text +*.yml text + +# Ensure those won't be messed up with +*.png binary +*.jpg binary +*.gif binary +*.ttf binary + +# Exclude files from the archive +/.editorconfig export-ignore +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.styleci.yml export-ignore +/codeception.yml export-ignore +/composer-require-checker.json export-ignore +/docs export-ignore +/ecs.php export-ignore +/infection.json* export-ignore +/phpstan*.neon* export-ignore +/phpunit.xml.dist export-ignore +/rector.php export-ignore +/runtime export-ignore +/tests export-ignore + +# Avoid merge conflicts in CHANGELOG +# https://about.gitlab.com/2015/02/10/gitlab-reduced-merge-conflicts-by-90-percent-with-changelog-placeholders/ +/CHANGELOG.md merge=union diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c718a98 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +--- +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ["*"] + + # Maintain dependencies for Composer + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + composer: + patterns: ["*"] + versioning-strategy: increase-if-necessary + + # Maintain dependencies for npm + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + npm: + patterns: ["*"] diff --git a/.github/linters/.codespellrc b/.github/linters/.codespellrc new file mode 100644 index 0000000..636396c --- /dev/null +++ b/.github/linters/.codespellrc @@ -0,0 +1,2 @@ +[codespell] +skip = ./.git,.claude/*,.codex/*,.config/gh/*,.github/agents/*,.github/copilot-instructions.md,.github/copilot/*,.github/instructions/*,.github/prompts/*,.github/skills/*,.vscode/*,*/composer.json,*/composer.lock,*/dist/*,*/node_modules/*,*/package-lock.json,*/tests/*,code_coverage/*,CHANGELOG.md,UPGRADE.md,runtime/*,scaffold-lock.json,vendor/* diff --git a/.github/linters/.editorconfig-checker.json b/.github/linters/.editorconfig-checker.json new file mode 100644 index 0000000..3b52d57 --- /dev/null +++ b/.github/linters/.editorconfig-checker.json @@ -0,0 +1,23 @@ +{ + "Exclude": [ + "\\.min\\.css$", + "\\.min\\.js$", + "^\\.claude/", + "^\\.codex/", + "^\\.config/gh/", + "^\\.github/agents/", + "^\\.github/copilot-instructions\\.md$", + "^\\.github/copilot/", + "^\\.github/instructions/", + "^\\.github/prompts/", + "^\\.github/skills/", + "^\\.vscode/", + "^code_coverage/", + "^composer\\.lock$", + "^runtime/", + "^tests/runtime/", + "^vendor/", + "phpstan-baseline\\.neon$", + "scaffold-lock\\.json$" + ] +} diff --git a/.github/linters/.gitleaks.toml b/.github/linters/.gitleaks.toml new file mode 100644 index 0000000..1f08510 --- /dev/null +++ b/.github/linters/.gitleaks.toml @@ -0,0 +1,4 @@ +title = "gitleaks config" + +[extend] +useDefault = true diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 0000000..0a6d8fc --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,5 @@ +--- +MD007: + indent: 2 +MD013: false +MD033: false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6924f18 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: build + +permissions: + contents: read + +jobs: + phpunit: + uses: yii2-framework/actions/.github/workflows/phpunit.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + concurrency-group: phpunit-${{ github.workflow }}-${{ github.ref }} + extensions: mbstring + php-version: '["8.3","8.4","8.5"]' diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml new file mode 100644 index 0000000..b3d45f9 --- /dev/null +++ b/.github/workflows/dependency-check.yml @@ -0,0 +1,20 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: Composer require checker + +permissions: + contents: read + +jobs: + composer-require-checker: + uses: yii2-framework/actions/.github/workflows/composer-require-checker.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 diff --git a/.github/workflows/ecs.yml b/.github/workflows/ecs.yml new file mode 100644 index 0000000..47e733c --- /dev/null +++ b/.github/workflows/ecs.yml @@ -0,0 +1,20 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: ecs + +permissions: + contents: read + +jobs: + easy-coding-standard: + uses: yii2-framework/actions/.github/workflows/ecs.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..907a3c9 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,25 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: mutation test + +permissions: + contents: read + +jobs: + mutation: + uses: yii2-framework/actions/.github/workflows/infection.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + with: + command-options: --threads=4 --ignore-msi-with-no-mutations + phpstan: true + secrets: + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..301ca10 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,22 @@ +--- +on: + - pull_request + - push + +name: quality + +permissions: + contents: read + +jobs: + quality: + uses: yii2-framework/actions/.github/workflows/quality.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + permissions: + contents: read + with: + codespell-config: .github/linters/.codespellrc + editorconfig-checker-config: .github/linters/.editorconfig-checker.json + markdownlint-config: .github/linters/.markdown-lint.yml + prettier-config: .prettierrc.json + prettier-ignore-path: .prettierignore + yamllint-targets: .github diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..029e387 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,19 @@ +--- +on: + - pull_request + - push + +name: security + +permissions: + contents: read + +jobs: + security: + uses: yii2-framework/actions/.github/workflows/security.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + permissions: + contents: read + with: + gitleaks-config: .github/linters/.gitleaks.toml + secrets: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..970f063 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,20 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: static analysis + +permissions: + contents: read + +jobs: + phpstan: + uses: yii2-framework/actions/.github/workflows/phpstan.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 diff --git a/.gitignore b/.gitignore index 70f0875..2d02692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,58 @@ -assets/* -!assets/.gitignore -protected/runtime/* -!protected/runtime/.gitignore -protected/data/*.db -themes/classic/views/ +# Local AI agent, editor, and GitHub CLI config (if present) +.claude +.codex +.config/gh + +# codecoverage (if present) +code_coverage + +# codeception (if present) +c3.php + +# composer +composer.lock + +# GitHub Copilot config (if present) +.github/agents/** +.github/copilot-instructions.md +.github/copilot/** +.github/instructions/** +.github/prompts/** +.github/skills/** + +# mac ds_store (if present) +.DS_Store + +# netbeans project (if present) +nbproject + +# node_modules (if present) +node_modules +package-lock.json + +# phpstorm project (if present) +.idea + +# phpactor (if present) +.phpactor.* + +# phpunit (if present) +.phpunit.cache +.phpunit.result.cache + +# vagrant (if present) +.vagrant + +# vendor +vendor + +# vscode project (if present) +.vscode + +# windows thumbnail cache (if present) +Thumbs.db + +# zend studio for eclipse project (if present) +.buildpath +.project +.settings diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7890c21 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,28 @@ +# Local agent, editor, coverage, and generated runtime state. +.claude/** +.codex/** +.config/gh/** +.github/agents/** +.github/copilot-instructions.md +.github/copilot/** +.github/instructions/** +.github/prompts/** +.github/skills/** +.vscode/** +code_coverage/** +runtime/** + +# Dependencies. +vendor/** + +# Dependency lockfile ignored by git. +composer.lock + +# License texts ship verbatim. +**/LICENSE* + +# Tool config maintained by hand to keep array order meaningful. +composer-require-checker.json + +# Auto-generated by yii2-extensions/scaffold. +scaffold-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..c9f33af --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,22 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 80, + "overrides": [ + { + "files": ["**/*.{yml,yaml}"], + "options": { + "tabWidth": 2 + } + }, + { + "files": "**/*.md", + "options": { + "embeddedLanguageFormatting": "off", + "tabWidth": 2 + } + } + ] +} diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..8995a69 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,88 @@ +preset: psr12 +risky: true + +version: 8.3 + +finder: + exclude: + - docs + - vendor + +enabled: + - alpha_ordered_traits + - array_indentation + - array_push + - combine_consecutive_issets + - combine_consecutive_unsets + - combine_nested_dirname + - declare_strict_types + - dir_constant + - empty_loop_body_braces + - function_to_constant + - hash_to_slash_comment + - integer_literal_case + - is_null + - logical_operators + - magic_constant_casing + - magic_method_casing + - method_separation + - modernize_types_casting + - native_function_casing + - native_function_type_declaration_casing + - no_alias_functions + - no_empty_comment + - no_empty_phpdoc + - no_empty_statement + - no_extra_block_blank_lines + - no_short_bool_cast + - no_superfluous_elseif + - no_unneeded_control_parentheses + - no_unneeded_curly_braces + - no_unneeded_final_method + - no_unset_cast + - no_unused_imports + - no_unused_lambda_imports + - no_useless_else + - no_useless_return + - normalize_index_brace + - php_unit_dedicate_assert + - php_unit_dedicate_assert_internal_type + - php_unit_expectation + - php_unit_mock + - php_unit_mock_short_will_return + - php_unit_namespaced + - php_unit_no_expectation_annotation + - phpdoc_no_empty_return + - phpdoc_no_useless_inheritdoc + - phpdoc_order + - phpdoc_param_order + - phpdoc_property + - phpdoc_scalar + - phpdoc_singular_inheritdoc + - phpdoc_trim + - phpdoc_trim_consecutive_blank_line_separation + - phpdoc_type_to_var + - phpdoc_types + - phpdoc_types_order + - print_to_echo + - regular_callable_call + - return_assignment + - self_accessor + - self_static_accessor + - set_type_to_cast + - short_array_syntax + - short_list_syntax + - simplified_if_return + - single_quote + - standardize_not_equals + - ternary_to_null_coalescing + - trailing_comma_in_multiline_array + - unalign_double_arrow + - unalign_equals + - union_type_without_spaces + +disabled: + - function_declaration + - new_with_parentheses + - psr12_braces + - psr12_class_definition diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..fd04c4f --- /dev/null +++ b/.stylelintignore @@ -0,0 +1 @@ +**/*.min.css diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..790ee38 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.1.0 Under development + +- feat: add framework-agnostic debug snapshot contracts, strict JSON hydration, and filesystem persistence. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18bdfd1 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Debug Core + +Framework-agnostic contracts, snapshots, and storage primitives for PHP debugger adapters. + +This package is the shared engine used by framework-specific integrations. Applications should install an adapter +instead of requiring this package directly. + +## Installation + +Adapter packages install Debug Core transitively. If you develop an adapter, run: + +```shell +composer require php-forge/debug-core +``` + +## Architecture + +The core package owns portable debug data and persistence. It does not depend on Yii2, Yii3, an application container, +or a framework request lifecycle. Adapters collect framework data and convert it into the immutable snapshots provided +by this package. + +Current adapters: + +- `yii2-extensions/debug` +- `yii3/debug` + +## License + +The package is released under the BSD-3-Clause license. See `LICENSE`. diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..af5da6d --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,16 @@ +{ + "symbol-whitelist": [], + "php-core-extensions": [ + "Core", + "date", + "json", + "hash", + "pcre", + "Phar", + "Reflection", + "SPL", + "random", + "standard" + ], + "scan-files": [] +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..16df7a7 --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "php-forge/debug-core", + "type": "library", + "description": "Framework-agnostic contracts, snapshots, and storage primitives for PHP debug adapters.", + "keywords": [ + "debug", + "debugger", + "developer-tools", + "php", + "profiling" + ], + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Wilmer Arambula", + "email": "terabytesoftw@gmail.com", + "homepage": "https://github.com/terabytesoftw", + "role": "Developer" + } + ], + "require": { + "php": ">=8.3", + "ext-mbstring": "*" + }, + "require-dev": { + "infection/infection": "^0.34", + "maglnet/composer-require-checker": "^4.1", + "php-forge/baseline": "^0.1", + "php-forge/coding-standard": "^0.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-strict-rules": "^2.0.3", + "phpunit/phpunit": "^12.5", + "xepozz/internal-mocker": "^1.4", + "yii2-extensions/scaffold": "^0.2" + }, + "autoload": { + "psr-4": { + "PHPForge\\Debug\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "PHPForge\\Debug\\Tests\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + }, + "scaffold": { + "allowed-packages": [ + "php-forge/baseline", + "php-forge/coding-standard" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true, + "phpstan/extension-installer": true, + "yii2-extensions/scaffold": true + } + }, + "scripts": { + "check-dependencies": "./vendor/bin/composer-require-checker check", + "ecs": "./vendor/bin/ecs --fix", + "mutation": "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100", + "mutation-static": "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan --static-analysis-tool-options='--memory-limit=-1'", + "rector": "./vendor/bin/rector process", + "static": "./vendor/bin/phpstan --memory-limit=-1", + "tests": "./vendor/bin/phpunit" + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..01e883d --- /dev/null +++ b/ecs.php @@ -0,0 +1,8 @@ +withPaths([__DIR__ . '/src', __DIR__ . '/tests']); diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..1d33ec4 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,12 @@ +{ + $schema: "./vendor/infection/infection/resources/schema.json", + logs: { + text: "php://stderr", + stryker: { + report: "main", + }, + }, + source: { + directories: ["src"], + }, +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..490633e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,20 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: max + + paths: + - src + - tests + + tmpDir: %currentWorkingDirectory%/runtime + + checkImplicitMixed: true + checkBenevolentUnionTypes: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + checkTooWideReturnTypesInProtectedAndPublicMethods: true + reportAnyTypeWideningInVarTag: true + reportPossiblyNonexistentConstantArrayOffset: true + reportPossiblyNonexistentGeneralArrayOffset: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2058c61 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + ./tests + + + + + src + + + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..4c86b22 --- /dev/null +++ b/rector.php @@ -0,0 +1,10 @@ +withPaths([__DIR__ . '/src', __DIR__ . '/tests']) + ->withPhpSets(php83: true) + ->withPreparedSets(codeQuality: true, deadCode: true, typeDeclarations: true); diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/scaffold-lock.json b/scaffold-lock.json new file mode 100644 index 0000000..e58b26c --- /dev/null +++ b/scaffold-lock.json @@ -0,0 +1,98 @@ +{ + "providers": { + "php-forge/baseline": { + "version": "0.1.7", + "path": "vendor/php-forge/baseline" + }, + "php-forge/coding-standard": { + "version": "0.3.3", + "path": "vendor/php-forge/coding-standard" + } + }, + "files": { + ".editorconfig": { + "hash": "sha256:f0cafe474d79d769f397f2e8839c642ca3ef1a0ce38b6e9f82ed9e49bccf84f6", + "provider": "php-forge/baseline", + "source": "metadata/.editorconfig", + "mode": "append" + }, + ".gitattributes": { + "hash": "sha256:88b62c371df01eac6e0298625a03baee372bd4048a6452cc2a1aeee0d49eba10", + "provider": "php-forge/baseline", + "source": "metadata/gitattributes", + "mode": "replace" + }, + ".gitignore": { + "hash": "sha256:7d7753b2cc6184d74fb22f78c6b351b6c9c9dc04bcabe5481a0ed64b06858597", + "provider": "php-forge/baseline", + "source": "metadata/.gitignore", + "mode": "append" + }, + ".styleci.yml": { + "hash": "sha256:825f8c744ffbd2bc2369712e7192b8694129a8ecbc3fd91dc9300fccecf827bd", + "provider": "php-forge/baseline", + "source": "metadata/.styleci.yml", + "mode": "replace" + }, + ".prettierignore": { + "hash": "sha256:f9190dda61229a5daf8df6113600eb8183bbf98d2a89d7b4944a889ddca0ac0d", + "provider": "php-forge/baseline", + "source": "metadata/.prettierignore", + "mode": "replace" + }, + ".prettierrc.json": { + "hash": "sha256:a03ca958c95e39bbfd12af061402d0caab14bb4db2997e2b5279f1cd801c71e0", + "provider": "php-forge/baseline", + "source": "metadata/.prettierrc.json", + "mode": "replace" + }, + ".stylelintignore": { + "hash": "sha256:12186f0a5c42b4f894f7bdda1b1ca5ebc69f92d906aa9075a459d4d08fe4edc1", + "provider": "php-forge/baseline", + "source": "metadata/.stylelintignore", + "mode": "replace" + }, + "composer-require-checker.json": { + "hash": "sha256:0cc82e1bb44b9b090ea8ff2f5c6ee51605d031eb8ac6ae8042a8584c2f14c91a", + "provider": "php-forge/baseline", + "source": "metadata/composer-require-checker.json", + "mode": "preserve" + }, + ".github/linters/.codespellrc": { + "hash": "sha256:0ec9825d6022c7b0edcdbc6f8a8e3fdea30bbd65f76084cc9f046bdd432bdc1f", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.codespellrc", + "mode": "replace" + }, + ".github/linters/.editorconfig-checker.json": { + "hash": "sha256:17587db6b4d163e43b47dd1f55809b2ab6f3a2d9b170ca88fdd23d703feb3064", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.editorconfig-checker.json", + "mode": "replace" + }, + ".github/linters/.gitleaks.toml": { + "hash": "sha256:4016e5e7237b525fd32eaf50c10577c21f452863ce95a901c059fb90d6b9318a", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.gitleaks.toml", + "mode": "replace" + }, + ".github/linters/.markdown-lint.yml": { + "hash": "sha256:5d41c7471e15fea1b307a39791436179c5db6ae7c7daf00042d21750117bead4", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.markdown-lint.yml", + "mode": "replace" + }, + "ecs.php": { + "hash": "sha256:b70701973dcf941f47b94c887745312865eea23bb11ff09fed0aac4f0b83bc03", + "provider": "php-forge/coding-standard", + "source": "src/config/ecs.php", + "mode": "preserve" + }, + "rector.php": { + "hash": "sha256:37b4e62167891b24d7669e0640136991f9b299375568fe50b765adc41b375297", + "provider": "php-forge/coding-standard", + "source": "src/config/rector.php", + "mode": "preserve" + } + } +} \ No newline at end of file diff --git a/src/Storage/ArrayPayloadSnapshot.php b/src/Storage/ArrayPayloadSnapshot.php new file mode 100644 index 0000000..6d5f972 --- /dev/null +++ b/src/Storage/ArrayPayloadSnapshot.php @@ -0,0 +1,127 @@ + true]); + * ``` + * + * @param array $values Raw payload captured for the request. + * + * @return self Snapshot containing tagged debug values. + */ + public static function capture(array $values): self + { + return new self( + DebugArray::capture($values), + ); + } + + /** + * Hydrates a dynamic payload from decoded JSON data. + * + * Usage example: + * + * ```php + * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) { + * use \PHPForge\Debug\Storage\ArrayPayloadSnapshot; + * + * protected static function payloadKey(): string + * { + * return 'data'; + * } + * }; + * $data = $prototype::capture(['enabled' => true])->jsonSerialize(); + * $snapshot = $prototype::fromArray($data, '$.panel'); + * ``` + * + * @param mixed $data Decoded JSON payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated dynamic payload snapshot. + */ + public static function fromArray(mixed $data, string $path): self + { + $key = self::payloadKey(); + + return new self( + Payload::object($data, $path) + ->shape([$key]) + ->debugArray($key), + ); + } + + /** + * Returns the tagged payload for JSON serialization. + * + * Usage example: + * + * ```php + * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) { + * use \PHPForge\Debug\Storage\ArrayPayloadSnapshot; + * + * protected static function payloadKey(): string + * { + * return 'data'; + * } + * }; + * $data = $prototype::capture(['enabled' => true])->jsonSerialize(); + * ``` + * + * @return array Tagged payload indexed by its persistence key. + */ + public function jsonSerialize(): array + { + return [ + self::payloadKey() => $this->payload->jsonSerialize(), + ]; + } + + /** + * Returns the JSON key used to persist the dynamic payload. + * + * @return string Persisted payload key. + */ + abstract protected static function payloadKey(): string; + + /** + * Returns the payload restored to plain PHP values. + * + * @return array Payload restored to plain PHP values. + */ + private function values(): array + { + return $this->payload->values(); + } +} diff --git a/src/Storage/DebugArray.php b/src/Storage/DebugArray.php new file mode 100644 index 0000000..41b0c56 --- /dev/null +++ b/src/Storage/DebugArray.php @@ -0,0 +1,103 @@ + true]); + * ``` + * + * @param array $value PHP values to capture. + * + * @return self Tagged array facade. + */ + public static function capture(array $value): self + { + return new self( + DebugValue::capture($value), + ); + } + + /** + * Hydrates a tagged debug array from decoded JSON data. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize(); + * $array = \PHPForge\Debug\Storage\DebugArray::fromArray($data, '$.panel.data'); + * ``` + * + * @param mixed $value Decoded tagged value. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated array facade. + */ + public static function fromArray(mixed $value, string $path): self + { + $debugValue = DebugValue::fromArray($value, $path); + + if ($debugValue->type !== 'array') { + throw HydrationException::at( + $path, + 'a tagged array', + ); + } + + return new self( + $debugValue, + ); + } + + /** + * Returns the tagged array for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize(); + * ``` + * + * @return array Tagged array payload. + */ + public function jsonSerialize(): array + { + return $this->value->jsonSerialize(); + } + + /** + * Returns captured entries as display-safe PHP values. + * + * Usage example: + * + * ```php + * $values = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->values(); + * ``` + * + * @return array Display-safe PHP values. + */ + public function values(): array + { + return $this->value->toDisplayEntries(); + } +} diff --git a/src/Storage/DebugSnapshot.php b/src/Storage/DebugSnapshot.php new file mode 100644 index 0000000..e46b291 --- /dev/null +++ b/src/Storage/DebugSnapshot.php @@ -0,0 +1,102 @@ +> $panels Serialized panel payloads indexed by panel ID. + * @param array $failures Panel failures indexed by panel ID. + */ + public function __construct(public RequestSummary $summary, public array $panels, public array $failures) {} + + /** + * Hydrates a versioned request envelope from decoded JSON data. + * + * Usage example: + * + * ```php + * $snapshot = \PHPForge\Debug\Storage\DebugSnapshot::fromArray($data); + * ``` + * + * @param mixed $data Decoded snapshot envelope. + * + * @return self Hydrated request snapshot. + */ + public static function fromArray(mixed $data): self + { + $payload = Payload::object($data) + ->shape( + [ + 'version', + 'summary', + 'panels', + 'failures', + ], + ); + + if ($payload->int('version') !== self::VERSION) { + throw HydrationException::at( + '$.version', + 'storage version ' . self::VERSION, + ); + } + + $panels = []; + + foreach ($payload->map('panels') as $id => $panel) { + $panels[$id] = Payload::object($panel, "$.panels.{$id}") + ->all(); + } + + $failures = []; + + foreach ($payload->map('failures') as $id => $failure) { + $failures[$id] = PanelFailure::fromArray($failure, "$.failures.{$id}"); + } + + return new self( + RequestSummary::fromArray($payload->raw('summary')), + $panels, + $failures, + ); + } + + /** + * Returns the request envelope for JSON serialization. + * + * Usage example: + * + * ```php + * $data = $snapshot->jsonSerialize(); + * ``` + * + * @return array Versioned request envelope. + */ + public function jsonSerialize(): array + { + return [ + 'version' => self::VERSION, + 'summary' => $this->summary->jsonSerialize(), + 'panels' => $this->panels, + 'failures' => array_map( + static fn(PanelFailure $failure): array => $failure->jsonSerialize(), + $this->failures, + ), + ]; + } +} diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php new file mode 100644 index 0000000..50a32d7 --- /dev/null +++ b/src/Storage/DebugValue.php @@ -0,0 +1,550 @@ + $entries Captured child values. + * @param string|null $className Captured object class or `null` for non-object values. + * @param string|null $resourceType Captured resource type or `null` for non-resource values. + * @param string|null $reason Truncation or unsupported-value reason, or `null` when not applicable. + */ + private function __construct( + public string $type, + public bool|float|int|string|null $value = null, + public array $entries = [], + public string|null $className = null, + public string|null $resourceType = null, + public string|null $reason = null, + ) {} + + /** + * Captures an arbitrary PHP value as JSON-safe tagged data. + * + * Usage example: + * + * ```php + * $value = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true]); + * ``` + * + * @param mixed $value PHP value to capture. + * + * @return self Tagged debug value. + */ + public static function capture(mixed $value): self + { + $objects = new SplObjectStorage(); + + $nodes = 0; + + return self::normalize($value, 0, $nodes, $objects); + } + + /** + * Hydrates a tagged debug value from decoded JSON data. + * + * Usage example: + * + * ```php + * $value = \PHPForge\Debug\Storage\DebugValue::fromArray(['type' => 'int', 'value' => 42]); + * ``` + * + * @param mixed $data Decoded tagged value. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated debug value. + */ + public static function fromArray(mixed $data, string $path = '$'): self + { + $payload = Payload::object($data, $path); + + $type = $payload->string('type'); + + $payload->shape( + match ($type) { + 'null' => ['type'], + 'bool', 'int', 'float', 'special-float', 'string' => ['type', 'value'], + 'binary' => ['type', 'encoding', 'data'], + 'array' => ['type', 'entries'], + 'object' => ['type', 'value', 'entries', 'class'], + 'resource' => ['type', 'resourceType'], + 'truncated', 'recursion', 'unsupported' => ['type', 'value', 'reason'], + default => throw HydrationException::at( + "{$path}.type", + 'a known debug-value type', + ), + } + ); + + return match ($type) { + 'null' => new self('null'), + 'bool' => new self('bool', $payload->bool('value')), + 'int' => new self('int', $payload->int('value')), + 'float' => new self('float', $payload->number('value')), + 'special-float' => self::fromSpecialFloat($payload, $path), + 'string' => new self('string', $payload->string('value')), + 'binary' => self::fromBinary($payload, $path), + 'array' => new self('array', entries: self::hydrateEntries($payload, $path)), + 'object' => new self( + 'object', + value: $payload->nullableString('value'), + entries: self::hydrateEntries($payload, $path), + className: $payload->string('class'), + ), + 'resource' => new self( + 'resource', + resourceType: $payload->string('resourceType'), + ), + 'truncated', 'recursion', 'unsupported' => new self( + $type, + value: $payload->nullableString('value'), + reason: $payload->string('reason'), + ), + }; + } + + /** + * Returns the tagged value for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true])->jsonSerialize(); + * ``` + * + * @return array Tagged debug value payload. + */ + public function jsonSerialize(): array + { + $data = ['type' => $this->type]; + + if (in_array($this->type, ['bool', 'int', 'float', 'special-float', 'string'], true)) { + $data['value'] = $this->value; + } + + if ($this->type === 'binary') { + $data['encoding'] = 'base64'; + + $data['data'] = base64_encode(is_string($this->value) ? $this->value : ''); + } + + if ($this->type === 'array' || $this->type === 'object') { + $data['entries'] = array_map( + static fn(array $entry): array => [ + 'keyType' => $entry['keyType'], + 'key' => $entry['key'], + 'value' => $entry['value']->jsonSerialize(), + ], + $this->entries, + ); + } + + if ($this->className !== null) { + $data['class'] = $this->className; + } + + if ($this->resourceType !== null) { + $data['resourceType'] = $this->resourceType; + } + + if ($this->reason !== null) { + $data['reason'] = $this->reason; + + $data['value'] = is_string($this->value) ? $this->value : null; + } + + if ($this->type === 'object') { + $data['value'] = is_string($this->value) ? $this->value : null; + } + + return $data; + } + + /** + * Returns the entries as a plain PHP array, keeping the key types captured from the original value. + * + * Usage example: + * + * ```php + * $entries = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true])->toDisplayEntries(); + * ``` + * + * @return array Display-safe child values retaining captured key types. + */ + public function toDisplayEntries(): array + { + $result = []; + + foreach ($this->entries as $entry) { + $result[$entry['key']] = $entry['value']->toDisplayValue(); + } + + return $result; + } + + /** + * Returns a safe PHP representation suitable for existing dump and table renderers. + * + * Usage example: + * + * ```php + * $displayValue = \PHPForge\Debug\Storage\DebugValue::capture(new \stdClass())->toDisplayValue(); + * ``` + * + * @return mixed Display-safe PHP value. + */ + public function toDisplayValue(): mixed + { + return match ($this->type) { + 'null' => null, + 'bool', 'int', 'float', 'string' => $this->value, + 'special-float', 'binary', 'resource', 'truncated', 'recursion', 'unsupported' => $this->displayLabel(), + 'array' => $this->toDisplayEntries(), + 'object' => ['__class' => $this->className ?? 'object'] + $this->toDisplayEntries(), + default => '(unsupported)', + }; + } + + /** + * Returns the display label for a non-scalar tagged value. + * + * @return string Display-safe label. + */ + private function displayLabel(): string + { + return match ($this->type) { + 'binary' => sprintf( + '(binary: base64 %s)', + base64_encode(is_string($this->value) ? $this->value : ''), + ), + 'resource' => sprintf('(resource: %s)', $this->resourceType ?? 'unknown'), + default => is_string($this->value) && $this->value !== '' + ? $this->value + : sprintf('(%s: %s)', $this->type, $this->reason ?? 'unknown'), + }; + } + + /** + * Hydrates a base64-encoded binary value. + * + * @param Payload $payload Validated tagged value payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated binary value. + */ + private static function fromBinary(Payload $payload, string $path): self + { + if ($payload->string('encoding') !== 'base64') { + throw HydrationException::at( + "{$path}.encoding", + 'base64', + ); + } + + $decoded = base64_decode($payload->string('data'), true); + + if ($decoded === false) { + throw HydrationException::at( + "{$path}.data", + 'valid base64 data', + ); + } + + return new self( + 'binary', + $decoded, + ); + } + + /** + * Hydrates a non-finite floating-point label. + * + * @param Payload $payload Validated tagged value payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated non-finite floating-point value. + */ + private static function fromSpecialFloat(Payload $payload, string $path): self + { + $value = $payload->string('value'); + + if (!in_array($value, ['NAN', 'INF', '-INF'], true)) { + throw HydrationException::at( + "{$path}.value", + 'NAN, INF, or -INF', + ); + } + + return new self( + 'special-float', + $value, + ); + } + + /** + * Hydrates tagged array or object entries. + * + * @param Payload $payload Validated tagged value payload. + * @param string $path Payload path used in hydration errors. + * + * @return list Hydrated entries. + */ + private static function hydrateEntries(Payload $payload, string $path): array + { + $entries = []; + + foreach ($payload->list('entries') as $index => $rawEntry) { + $entryPath = "{$path}.entries[{$index}]"; + + $entry = Payload::object($rawEntry, $entryPath) + ->shape( + [ + 'keyType', + 'key', + 'value', + ], + ); + $keyType = $entry->string('keyType'); + $key = $entry->raw('key'); + + if ( + ($keyType !== 'int' && $keyType !== 'string') + || ($keyType === 'int' && !is_int($key)) + || ($keyType === 'string' && !is_string($key)) + ) { + throw HydrationException::at( + "{$entryPath}.key", + 'a key matching keyType', + ); + } + + $entries[] = [ + 'keyType' => $keyType, + 'key' => $key, + 'value' => self::fromArray($entry->raw('value'), "{$entryPath}.value"), + ]; + } + + return $entries; + } + + /** + * Normalizes a PHP value while enforcing depth and node limits. + * + * @param mixed $value PHP value to normalize. + * @param int $depth Current nesting depth. + * @param int $nodes Number of values visited across the capture operation. + * @param SplObjectStorage $objects Objects active in the current traversal path. + * + * @return self Normalized debug value. + */ + private static function normalize( + mixed $value, + int $depth, + int &$nodes, + SplObjectStorage $objects, + ): self { + if (++$nodes > self::MAX_NODES) { + return new self( + 'truncated', + value: '*SKIPPED over 10000 nodes*', + reason: 'size', + ); + } + + if ($depth > self::MAX_DEPTH) { + return new self( + 'truncated', + value: '*DEEP NESTED VALUE*', + reason: 'depth', + ); + } + + if ($value === null) { + return new self( + 'null', + ); + } + + if (is_bool($value)) { + return new self( + 'bool', + $value, + ); + } + + if (is_int($value)) { + return new self( + 'int', + $value, + ); + } + + if (is_float($value)) { + return is_finite($value) + ? new self( + 'float', + $value, + ) + : new self( + 'special-float', + match (true) { + is_nan($value) => 'NAN', + $value === INF => 'INF', + default => '-INF', + }, + ); + } + + if (is_string($value)) { + return mb_check_encoding($value, 'UTF-8') + ? new self( + 'string', + $value, + ) + : new self( + 'binary', + $value, + ); + } + + if (is_array($value)) { + $entries = []; + + foreach ($value as $key => $entry) { + $entries[] = [ + 'keyType' => is_int($key) ? 'int' : 'string', + 'key' => is_int($key) ? $key : Json::safeString($key), + 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), + ]; + + if ($nodes > self::MAX_NODES) { + break; + } + } + + return new self( + 'array', + entries: $entries, + ); + } + + if (is_object($value)) { + if ($objects->offsetExists($value)) { + return new self( + 'recursion', + value: $value::class, + reason: 'object-cycle', + ); + } + + $objects->offsetSet($value); + $entries = []; + + foreach (get_object_vars($value) as $key => $entry) { + $entries[] = [ + 'keyType' => 'string', + 'key' => Json::safeString((string) $key), + 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), + ]; + + if ($nodes > self::MAX_NODES) { + break; + } + } + + $objects->offsetUnset($value); + + return new self( + 'object', + value: self::objectLabel($value), + entries: $entries, + className: $value::class, + ); + } + + if (is_resource($value)) { + return new self( + 'resource', + resourceType: get_resource_type($value), + ); + } + + // Closed resources report `false` from is_resource(), so they land here rather than in the branch above. + return new self( + 'unsupported', + value: '(unsupported)', + reason: 'unknown-type', + ); + } + + /** + * Returns a safe display label for an object. + * + * @param object $value Object to describe. + * + * @return string Display-safe object label. + */ + private static function objectLabel(object $value): string + { + if ($value instanceof Closure) { + return '\\Closure'; + } + + if ($value instanceof Throwable) { + $class = $value::class; + $message = $value->getMessage(); + + return Json::safeString($class) . ': ' . Json::safeString($message); + } + + if ($value instanceof Stringable) { + try { + return Json::safeString((string) $value); + } catch (Throwable) { + // Fall through to the class name when userland string conversion fails. + } + } + + return Json::safeString($value::class); + } +} diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php new file mode 100644 index 0000000..799810e --- /dev/null +++ b/src/Storage/ExceptionSnapshot.php @@ -0,0 +1,383 @@ + $trace Captured trace frames. + * @param string $toString Original throwable text. + * @param self|null $previous Previous throwable snapshot or `null`. + */ + public function __construct( + private string $class, + private string $message, + private int|string $code, + private string $file, + private int $line, + private array $trace, + private string $toString, + private self|null $previous, + ) {} + + /** + * Returns the original throwable text captured in the snapshot. + * + * @return string Original throwable text. + */ + public function __toString(): string + { + return $this->toString; + } + + /** + * Hydrates a throwable snapshot from decoded JSON data. + * + * Usage example: + * + * ```php + * $captured = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException('Failed.')); + * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromArray($captured->jsonSerialize()); + * ``` + * + * @param mixed $data Decoded throwable payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated throwable snapshot. + */ + public static function fromArray(mixed $data, string $path = '$.exception'): self + { + $payload = Payload::object($data, $path) + ->shape( + [ + 'class', + 'message', + 'code', + 'file', + 'line', + 'trace', + 'toString', + 'previous', + ], + ); + + $rawCode = $payload->raw('code'); + + if (!is_int($rawCode) && !is_string($rawCode)) { + throw HydrationException::at( + "{$path}.code", + 'an integer or string', + ); + } + + $trace = []; + + foreach ($payload->list('trace') as $index => $rawFrame) { + $framePath = "{$path}.trace[{$index}]"; + + $frame = Payload::object($rawFrame, $framePath) + ->shape( + [ + 'namespace', + 'short_class', + 'class', + 'type', + 'function', + 'file', + 'line', + 'args', + ], + ); + + $trace[] = [ + 'namespace' => $frame->string('namespace'), + 'short_class' => $frame->string('short_class'), + 'class' => $frame->string('class'), + 'type' => $frame->string('type'), + 'function' => $frame->nullableString('function'), + 'file' => $frame->nullableString('file'), + 'line' => $frame->nullableInt('line'), + 'args' => DebugArray::fromArray($frame->raw('args'), "{$framePath}.args"), + ]; + } + + $previous = $payload->raw('previous'); + + return new self( + class: $payload->string('class'), + message: $payload->string('message'), + code: $rawCode, + file: $payload->string('file'), + line: $payload->int('line'), + trace: $trace, + toString: $payload->string('toString'), + previous: $previous === null ? null : self::fromArray($previous, "{$path}.previous"), + ); + } + + /** + * Captures a throwable and its previous-exception chain without executable state. + * + * Usage example: + * + * ```php + * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Capture failed.'), + * ); + * ``` + * + * @param Throwable $throwable Throwable to capture. + * + * @return self Captured throwable snapshot. + */ + public static function fromThrowable(Throwable $throwable): self + { + $trace = []; + + foreach ($throwable->getTrace() as $entry) { + $class = is_string($entry['class'] ?? null) ? $entry['class'] : ''; + $args = is_array($entry['args'] ?? null) ? $entry['args'] : []; + + $trace[] = [ + 'namespace' => Json::safeString(self::namespacePart($class)), + 'short_class' => Json::safeString(self::shortName($class)), + 'class' => Json::safeString($class), + 'type' => Json::safeString(is_string($entry['type'] ?? null) ? $entry['type'] : ''), + 'function' => Json::safeString($entry['function']), + 'file' => is_string($entry['file'] ?? null) ? Json::safeString($entry['file']) : null, + 'line' => is_int($entry['line'] ?? null) ? $entry['line'] : null, + 'args' => DebugArray::capture($args), + ]; + } + + $code = $throwable->getCode(); + + try { + $toString = (string) $throwable; + } catch (Throwable) { + $toString = $throwable::class . ': ' . $throwable->getMessage(); + } + + return new self( + class: Json::safeString($throwable::class), + message: Json::safeString($throwable->getMessage()), + code: $code, + file: Json::safeString($throwable->getFile()), + line: $throwable->getLine(), + trace: $trace, + toString: Json::safeString($toString), + previous: $throwable->getPrevious() !== null ? self::fromThrowable($throwable->getPrevious()) : null, + ); + } + + /** + * Returns the captured throwable class. + * + * Usage example: + * + * ```php + * $class = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getClass(); + * ``` + * + * @return string Captured throwable class. + */ + public function getClass(): string + { + return $this->class; + } + + /** + * Returns the captured throwable code. + * + * Usage example: + * + * ```php + * $code = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException('', 42))->getCode(); + * ``` + * + * @return int|string Captured throwable code. + */ + public function getCode(): int|string + { + return $this->code; + } + + /** + * Returns the file where the throwable originated. + * + * Usage example: + * + * ```php + * $file = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getFile(); + * ``` + * + * @return string Origin file path. + */ + public function getFile(): string + { + return $this->file; + } + + /** + * Returns the line where the throwable originated. + * + * Usage example: + * + * ```php + * $line = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getLine(); + * ``` + * + * @return int Origin line number. + */ + public function getLine(): int + { + return $this->line; + } + + /** + * Returns the captured throwable message. + * + * Usage example: + * + * ```php + * $message = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Capture failed.'), + * )->getMessage(); + * ``` + * + * @return string Captured throwable message. + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Returns the previous throwable snapshot or `null`. + * + * Usage example: + * + * ```php + * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Outer.', 0, new \LogicException('Inner.')), + * ); + * $previous = $snapshot->getPrevious(); + * ``` + * + * @return self|null Previous throwable snapshot or `null`. + */ + public function getPrevious(): self|null + { + return $this->previous; + } + + /** + * Returns the trace frames with their arguments projected to plain display values. + * + * Usage example: + * + * ```php + * $trace = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getTrace(); + * ``` + * + * @return list> Display-safe trace frames. + */ + public function getTrace(): array + { + return array_map( + static fn(array $frame): array => [...$frame, 'args' => $frame['args']->values()], + $this->trace, + ); + } + + /** + * Returns the throwable snapshot for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Capture failed.'), + * )->jsonSerialize(); + * ``` + * + * @return array Serialized throwable snapshot. + */ + public function jsonSerialize(): array + { + return [ + 'class' => $this->class, + 'message' => $this->message, + 'code' => $this->code, + 'file' => $this->file, + 'line' => $this->line, + 'trace' => array_map( + static fn(array $frame): array => [...$frame, 'args' => $frame['args']->jsonSerialize()], + $this->trace, + ), + 'toString' => $this->toString, + 'previous' => $this->previous?->jsonSerialize(), + ]; + } + + /** + * Returns the namespace portion of a fully qualified class name. + * + * @param string $class Fully qualified class name. + * + * @return string Namespace portion or an empty string. + */ + private static function namespacePart(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? '' : substr($class, 0, $position); + } + + /** + * Returns the short portion of a fully qualified class name. + * + * @param string $class Fully qualified class name. + * + * @return string Short class name. + */ + private static function shortName(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? $class : substr($class, $position + 1); + } +} diff --git a/src/Storage/HydrationException.php b/src/Storage/HydrationException.php new file mode 100644 index 0000000..427dde7 --- /dev/null +++ b/src/Storage/HydrationException.php @@ -0,0 +1,34 @@ + $entries Request summaries indexed by tag. + */ + public function __construct(public array $entries) {} + + /** + * Hydrates a versioned manifest from decoded JSON data. + * + * Usage example: + * + * ```php + * $manifest = \PHPForge\Debug\Storage\Manifest::fromArray($data); + * ``` + * + * @param mixed $data Decoded manifest payload. + * + * @return self Hydrated request-summary index. + */ + public static function fromArray(mixed $data): self + { + $payload = Payload::object($data) + ->shape( + [ + 'version', + 'entries', + ], + ); + + if ($payload->int('version') !== DebugSnapshot::VERSION) { + throw HydrationException::at( + '$.version', + 'storage version ' . DebugSnapshot::VERSION, + ); + } + + $entries = []; + + foreach ($payload->map('entries') as $tag => $entry) { + $summary = RequestSummary::fromArray($entry, "$.entries.{$tag}"); + + if ($summary->tag !== $tag) { + throw HydrationException::at( + "$.entries.{$tag}.tag", + "the manifest key '{$tag}'", + ); + } + + $entries[$tag] = $summary; + } + + return new self( + $entries, + ); + } + + /** + * Returns the versioned manifest for JSON serialization. + * + * Usage example: + * + * ```php + * $data = (new \PHPForge\Debug\Storage\Manifest([]))->jsonSerialize(); + * ``` + * + * @return array Versioned manifest payload. + */ + public function jsonSerialize(): array + { + return [ + 'version' => DebugSnapshot::VERSION, + 'entries' => array_map( + static fn(RequestSummary $summary): array => $summary->jsonSerialize(), + $this->entries, + ), + ]; + } +} diff --git a/src/Storage/PanelFailure.php b/src/Storage/PanelFailure.php new file mode 100644 index 0000000..46b6d61 --- /dev/null +++ b/src/Storage/PanelFailure.php @@ -0,0 +1,111 @@ +shape( + [ + 'stage', + 'exception', + ], + ); + + $stage = $payload->string('stage'); + + if ($stage !== self::CAPTURE && $stage !== self::HYDRATE) { + throw HydrationException::at( + "{$path}.stage", + 'capture or hydrate', + ); + } + + return new self( + $stage, + ExceptionSnapshot::fromArray($payload->raw('exception'), "{$path}.exception"), + ); + } + + /** + * Captures a throwable raised during a panel lifecycle stage. + * + * Usage example: + * + * ```php + * $failure = \PHPForge\Debug\Storage\PanelFailure::fromThrowable( + * \PHPForge\Debug\Storage\PanelFailure::CAPTURE, + * new \RuntimeException('Capture failed.'), + * ); + * ``` + * + * @param 'capture'|'hydrate' $stage Lifecycle stage the panel failed in. + * @param Throwable $throwable Panel exception to capture. + * + * @return self Captured panel failure. + */ + public static function fromThrowable(string $stage, Throwable $throwable): self + { + return new self( + $stage, + ExceptionSnapshot::fromThrowable($throwable), + ); + } + + /** + * Returns the failure record for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\PanelFailure::fromThrowable( + * \PHPForge\Debug\Storage\PanelFailure::CAPTURE, + * new \RuntimeException('Capture failed.'), + * )->jsonSerialize(); + * ``` + * + * @return array Serialized failure stage and exception. + */ + public function jsonSerialize(): array + { + return [ + 'stage' => $this->stage, + 'exception' => $this->exception->jsonSerialize(), + ]; + } +} diff --git a/src/Storage/PanelRow.php b/src/Storage/PanelRow.php new file mode 100644 index 0000000..aea5367 --- /dev/null +++ b/src/Storage/PanelRow.php @@ -0,0 +1,29 @@ +jsonSerialize(); + * ``` + * + * @return array Serialized row fields. + */ + public function jsonSerialize(): array; +} diff --git a/src/Storage/PanelSnapshot.php b/src/Storage/PanelSnapshot.php new file mode 100644 index 0000000..7bcaeb2 --- /dev/null +++ b/src/Storage/PanelSnapshot.php @@ -0,0 +1,26 @@ +jsonSerialize(); + * ``` + * + * @return array Serialized panel fields. + */ + public function jsonSerialize(): array; +} diff --git a/src/Storage/Payload.php b/src/Storage/Payload.php new file mode 100644 index 0000000..58c1b20 --- /dev/null +++ b/src/Storage/Payload.php @@ -0,0 +1,467 @@ + $data Decoded JSON object. + * @param string $path Object path used in hydration errors. + */ + private function __construct(private array $data, private string $path) {} + + /** + * Returns every decoded field without conversion. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->all(); + * ``` + * + * @return array Decoded object fields. + */ + public function all(): array + { + return $this->data; + } + + /** + * Returns a required boolean field. + * + * Usage example: + * + * ```php + * $enabled = \PHPForge\Debug\Storage\Payload::object(['enabled' => true])->bool('enabled'); + * ``` + * + * @param string $key Required field name. + * + * @return bool Boolean field value. + */ + public function bool(string $key): bool + { + $value = $this->value($key); + + if (!is_bool($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a boolean', + ); + } + + return $value; + } + + /** + * Reads a tagged array value, keeping the path of the enclosing payload for error reporting. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize(); + * $array = \PHPForge\Debug\Storage\Payload::object(['data' => $data])->debugArray('data'); + * ``` + * + * @param string $key Required field name. + * + * @return DebugArray Hydrated tagged array. + */ + public function debugArray(string $key): DebugArray + { + return DebugArray::fromArray($this->value($key), $this->keyPath($key)); + } + + /** + * Returns a required integer field. + * + * Usage example: + * + * ```php + * $count = \PHPForge\Debug\Storage\Payload::object(['count' => 3])->int('count'); + * ``` + * + * @param string $key Required field name. + * + * @return int Integer field value. + */ + public function int(string $key): int + { + $value = $this->value($key); + + if (!is_int($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'an integer', + ); + } + + return $value; + } + + /** + * Returns a required list field. + * + * Usage example: + * + * ```php + * $items = \PHPForge\Debug\Storage\Payload::object(['items' => ['one', 'two']])->list('items'); + * ``` + * + * @param string $key Required field name. + * + * @return list List field value. + */ + public function list(string $key): array + { + $value = $this->value($key); + + if (!is_array($value) || !array_is_list($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a list', + ); + } + + return $value; + } + + /** + * Returns a required JSON object as a `string`-keyed array. + * + * Usage example: + * + * ```php + * $options = \PHPForge\Debug\Storage\Payload::object(['options' => ['enabled' => true]])->map('options'); + * ``` + * + * @param string $key Required field name. + * + * @return array Object field value. + */ + public function map(string $key): array + { + return self::object($this->value($key), $this->keyPath($key))->data; + } + + /** + * Returns an integer field or `null`. + * + * Usage example: + * + * ```php + * $line = \PHPForge\Debug\Storage\Payload::object(['line' => null])->nullableInt('line'); + * ``` + * + * @param string $key Required field name. + * + * @return int|null Integer field value or `null`. + */ + public function nullableInt(string $key): int|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_int($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'an integer or null', + ); + } + + return $value; + } + + /** + * Returns a numeric field as a float or `null`. + * + * Usage example: + * + * ```php + * $duration = \PHPForge\Debug\Storage\Payload::object(['duration' => 1.5])->nullableNumber('duration'); + * ``` + * + * @param string $key Required field name. + * + * @return float|null Numeric field value or `null`. + */ + public function nullableNumber(string $key): float|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_int($value) && (!is_float($value) || !is_finite($value))) { + throw HydrationException::at( + $this->keyPath($key), + 'a number or null', + ); + } + + return $value; + } + + /** + * Returns a string field or `null`. + * + * Usage example: + * + * ```php + * $action = \PHPForge\Debug\Storage\Payload::object(['action' => null])->nullableString('action'); + * ``` + * + * @param string $key Required field name. + * + * @return string|null String field value or `null`. + */ + public function nullableString(string $key): string|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_string($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a string or null', + ); + } + + return $value; + } + + /** + * Returns a required numeric field as a float. + * + * Usage example: + * + * ```php + * $duration = \PHPForge\Debug\Storage\Payload::object(['duration' => 1.5])->number('duration'); + * ``` + * + * @param string $key Required field name. + * + * @return float Numeric field value. + */ + public function number(string $key): float + { + $value = $this->value($key); + + if (!is_int($value) && (!is_float($value) || !is_finite($value))) { + throw HydrationException::at( + $this->keyPath($key), + 'a number', + ); + } + + return (float) $value; + } + + /** + * Creates a strict reader for a decoded JSON object. + * + * Usage example: + * + * ```php + * $payload = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'], '$.panel'); + * ``` + * + * @param mixed $value Decoded JSON value. + * @param string $path Object path used in hydration errors. + * + * @return self Strict object reader. + */ + public static function object(mixed $value, string $path = '$'): self + { + if (!is_array($value) || (array_is_list($value) && $value !== [])) { + throw HydrationException::at( + $path, + 'an object', + ); + } + + foreach (array_keys($value) as $key) { + if (!is_string($key)) { + throw HydrationException::at( + $path, + 'an object with string keys', + ); + } + } + + /** @var array $value */ + return new self( + $value, + $path, + ); + } + + /** + * Returns a required field without conversion. + * + * Usage example: + * + * ```php + * $value = \PHPForge\Debug\Storage\Payload::object(['value' => ['nested']])->raw('value'); + * ``` + * + * @param string $key Required field name. + * + * @return mixed Unconverted field value. + */ + public function raw(string $key): mixed + { + return $this->value($key); + } + + /** + * Reads a list of JSON objects, validating each element's shape but leaving its values untouched. + * + * Usage example: + * + * ```php + * $rows = \PHPForge\Debug\Storage\Payload::object(['rows' => [['name' => 'debug']]])->rows('rows'); + * ``` + * + * @param string $key Required field name. + * + * @return list> Validated object rows. + */ + public function rows(string $key): array + { + $path = $this->keyPath($key); + + $rows = []; + + foreach ($this->list($key) as $index => $row) { + $rows[] = self::object($row, "{$path}[{$index}]")->data; + } + + return $rows; + } + + /** + * Validates required, optional, and undeclared fields. + * + * Usage example: + * + * ```php + * $payload = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->shape(['name'], ['description']); + * ``` + * + * @param list $required Required field names. + * @param list $optional Optional field names. + * + * @return self Validated object reader. + */ + public function shape(array $required, array $optional = []): self + { + foreach ($required as $key) { + if (!array_key_exists($key, $this->data)) { + throw HydrationException::at( + "{$this->path}.{$key}", + 'a required field', + ); + } + } + + $unknown = array_diff(array_keys($this->data), [...$required, ...$optional]); + + if ($unknown !== []) { + $key = array_values($unknown)[0]; + + throw HydrationException::at( + "{$this->path}.{$key}", + 'a declared field', + ); + } + + return $this; + } + + /** + * Returns a required `string` field. + * + * Usage example: + * + * ```php + * $name = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->string('name'); + * ``` + * + * @param string $key Required field name. + * + * @return string String field value. + */ + public function string(string $key): string + { + $value = $this->value($key); + + if (!is_string($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a string', + ); + } + + return $value; + } + + /** + * Returns the absolute payload path for a field. + * + * @param string $key Field name. + * + * @return string Absolute field path. + */ + private function keyPath(string $key): string + { + return "{$this->path}.{$key}"; + } + + /** + * Returns a required field without conversion. + * + * @param string $key Required field name. + * + * @return mixed Unconverted field value. + */ + private function value(string $key): mixed + { + if (!array_key_exists($key, $this->data)) { + throw HydrationException::at( + $this->keyPath($key), + 'a required field', + ); + } + + return $this->data[$key]; + } +} diff --git a/src/Storage/RequestSummary.php b/src/Storage/RequestSummary.php new file mode 100644 index 0000000..7d25ce3 --- /dev/null +++ b/src/Storage/RequestSummary.php @@ -0,0 +1,176 @@ + $mailFiles Captured mail file paths. + * @param float|null $processingTime Processing duration in seconds or `null` when unavailable. + * @param int|null $peakMemory Peak memory in bytes or `null` when unavailable. + */ + public function __construct( + public string $tag, + public string $url, + public bool $ajax, + public string $method, + public string $ip, + public float $time, + public int $statusCode, + public int $sqlCount, + public int $excessiveCallersCount, + public int $mailCount, + public array $mailFiles, + public float|null $processingTime, + public int|null $peakMemory, + ) {} + + /** + * Hydrates request metadata from decoded JSON data. + * + * Usage example: + * + * ```php + * $summary = \PHPForge\Debug\Storage\RequestSummary::fromArray($data); + * ``` + * + * @param mixed $data Decoded request metadata. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated request metadata. + */ + public static function fromArray(mixed $data, string $path = '$.summary'): self + { + $payload = Payload::object($data, $path) + ->shape( + [ + 'tag', + 'url', + 'ajax', + 'method', + 'ip', + 'time', + 'statusCode', + 'sqlCount', + 'excessiveCallersCount', + 'mailCount', + 'mailFiles', + 'processingTime', + 'peakMemory', + ], + ); + + $mailFiles = []; + + foreach ($payload->list('mailFiles') as $index => $file) { + if (!is_string($file)) { + throw HydrationException::at( + "{$path}.mailFiles[{$index}]", + 'a string', + ); + } + + $mailFiles[] = $file; + } + + return new self( + tag: $payload->string('tag'), + url: $payload->string('url'), + ajax: $payload->bool('ajax'), + method: $payload->string('method'), + ip: $payload->string('ip'), + time: $payload->number('time'), + statusCode: $payload->int('statusCode'), + sqlCount: $payload->int('sqlCount'), + excessiveCallersCount: $payload->int('excessiveCallersCount'), + mailCount: $payload->int('mailCount'), + mailFiles: $mailFiles, + processingTime: $payload->nullableNumber('processingTime'), + peakMemory: $payload->nullableInt('peakMemory'), + ); + } + + /** + * Returns the request metadata for JSON serialization. + * + * Usage example: + * + * ```php + * $data = $summary->jsonSerialize(); + * ``` + * + * @return array Serialized request metadata. + */ + public function jsonSerialize(): array + { + return [ + 'tag' => $this->tag, + 'url' => $this->url, + 'ajax' => $this->ajax, + 'method' => $this->method, + 'ip' => $this->ip, + 'time' => $this->time, + 'statusCode' => $this->statusCode, + 'sqlCount' => $this->sqlCount, + 'excessiveCallersCount' => $this->excessiveCallersCount, + 'mailCount' => $this->mailCount, + 'mailFiles' => $this->mailFiles, + 'processingTime' => $this->processingTime, + 'peakMemory' => $this->peakMemory, + ]; + } + + /** + * Returns a copy enriched with processing time and peak memory usage. + * + * Usage example: + * + * ```php + * $profiledSummary = $summary->withProfiling(0.015, 2_097_152); + * ``` + * + * @param float $processingTime Processing duration in seconds. + * @param int $peakMemory Peak memory in bytes. + * + * @return self Request metadata enriched with profiling values. + */ + public function withProfiling(float $processingTime, int $peakMemory): self + { + return new self( + tag: $this->tag, + url: $this->url, + ajax: $this->ajax, + method: $this->method, + ip: $this->ip, + time: $this->time, + statusCode: $this->statusCode, + sqlCount: $this->sqlCount, + excessiveCallersCount: $this->excessiveCallersCount, + mailCount: $this->mailCount, + mailFiles: $this->mailFiles, + processingTime: $processingTime, + peakMemory: $peakMemory, + ); + } +} diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php new file mode 100644 index 0000000..5005917 --- /dev/null +++ b/src/Storage/SnapshotStore.php @@ -0,0 +1,458 @@ +clear(); + * ``` + */ + public function clear(): void + { + $this->initialize(); + + $lock = $this->acquireLock(LOCK_EX); + $patterns = [ + "{$this->path}/*.json", + "{$this->path}/.debug-*", + ]; + + foreach ($patterns as $pattern) { + $files = glob($pattern); + + foreach ($files === false ? [] : $files as $file) { + if (is_file($file) && !@unlink($file)) { + throw new StorageException( + "Unable to remove debug data file: {$file}", + ); + } + } + } + + fclose($lock); + } + + /** + * Returns manifest entries ordered from newest to oldest. + * + * Usage example: + * + * ```php + * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null); + * $entries = $store->loadManifest(); + * ``` + * + * @return array Newest entries first. + */ + public function loadManifest(): array + { + try { + $lock = $this->acquireLock(LOCK_SH); + } catch (StorageException) { + return []; + } + + try { + $manifest = $this->readManifestFile(); + + return $manifest === null ? [] : array_reverse($manifest->entries, true); + } finally { + fclose($lock); + } + } + + /** + * Returns a stored snapshot or `null` when the tag or persisted payload is invalid. + * + * Usage example: + * + * ```php + * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null); + * $snapshot = $store->readSnapshot('request-1'); + * ``` + * + * @param string $tag Snapshot tag. + * + * @return DebugSnapshot|null Hydrated snapshot or `null` when the stored value is unavailable or invalid. + */ + public function readSnapshot(string $tag): DebugSnapshot|null + { + if (!self::isValidTag($tag)) { + return null; + } + + $raw = @file_get_contents($this->snapshotFile($tag)); + + if ($raw === false || $raw === '') { + return null; + } + + try { + return DebugSnapshot::fromArray(self::decode($raw)); + } catch (Throwable) { + return null; + } + } + + /** + * Writes a snapshot, updates the manifest, and runs garbage collection under one exclusive lock. + * + * Usage example: + * + * ```php + * $removed = $store->writeSnapshot($snapshot, 50); + * ``` + * + * @param DebugSnapshot $snapshot Snapshot to persist. + * @param int $historySize Maximum number of retained entries. + * + * @return list Entries evicted from the manifest. + */ + public function writeSnapshot(DebugSnapshot $snapshot, int $historySize): array + { + self::assertValidHistorySize($historySize); + + $tag = $snapshot->summary->tag; + + $snapshotFile = $this->snapshotFile($tag); + $snapshotJson = self::encode($snapshot); + + $this->initialize(); + + $lock = $this->acquireLock(LOCK_EX); + + try { + $manifest = $this->readManifestFile(); + + $removeStaleSnapshots = $manifest === null; + $entries = $manifest instanceof Manifest ? $manifest->entries : []; + + unset($entries[$tag]); + + $entries[$tag] = $snapshot->summary; + + $removed = $this->collectGarbage($entries, $historySize); + + if ($entries !== []) { + $this->atomicWrite($snapshotFile, $snapshotJson); + } + + if ($removed !== []) { + $removeStaleSnapshots = true; + } + + $this->atomicWrite($this->indexFile(), self::encode(new Manifest($entries))); + + if ($removeStaleSnapshots) { + $this->removeStaleSnapshots($entries); + } + + return $removed; + } finally { + fclose($lock); + } + } + + /** + * Opens and acquires a checked filesystem lock. + * + * @param int<0, 7> $operation Lock operation passed to {@see flock()}. + * + * @return resource Acquired lock handle. + */ + private function acquireLock(int $operation): mixed + { + $lockFile = $this->lockFile(); + + $lock = @fopen($lockFile, 'c+'); + + if ($lock === false) { + throw new StorageException( + "Unable to open debug data lock file: {$lockFile}", + ); + } + + if (!@flock($lock, $operation)) { + fclose($lock); + + throw new StorageException( + "Unable to acquire debug data lock: {$lockFile}", + ); + } + + return $lock; + } + + /** + * Validates a manifest history size. + * + * @param int $historySize Maximum number of retained entries. + */ + private static function assertValidHistorySize(int $historySize): void + { + if ($historySize < 0) { + throw new StorageException( + "Invalid debug history size: {$historySize}", + ); + } + } + + /** + * Replaces a target file through a temporary file in the same directory. + * + * @param string $file Target file path. + * @param string $contents JSON contents to write. + */ + private function atomicWrite(string $file, string $contents): void + { + $temporary = @tempnam($this->path, '.debug-'); + + if ($temporary === false) { + throw new StorageException( + "Unable to write temporary debug data file for: {$file}", + ); + } + + if (file_put_contents($temporary, $contents) === false) { + @unlink($temporary); + + throw new StorageException( + "Unable to write temporary debug data file for: {$file}", + ); + } + + if ($this->fileMode !== null) { + @chmod($temporary, $this->fileMode); + } + + if (!@rename($temporary, $file)) { + @unlink($temporary); + + throw new StorageException( + "Unable to replace debug data file: {$file}", + ); + } + } + + /** + * Removes expired manifest entries. + * + * @param array $entries Manifest entries, updated in place. + * @param int $historySize Maximum number of retained entries. + * + * @return list Removed manifest entries. + */ + private function collectGarbage(array &$entries, int $historySize): array + { + if (count($entries) <= $historySize) { + return []; + } + + $remaining = count($entries) - $historySize; + + $removed = []; + + foreach (array_keys($entries) as $tag) { + $removed[] = $entries[$tag]; + + unset($entries[$tag]); + + if (--$remaining <= 0) { + break; + } + } + + return $removed; + } + + /** + * Decodes JSON and throws when the payload is invalid. + * + * @param string $json JSON document to decode. + * + * @throws JsonException if the JSON is invalid. + * + * @return mixed Decoded JSON value. + */ + private static function decode(string $json): mixed + { + return json_decode($json, true, flags: JSON_THROW_ON_ERROR); + } + + /** + * Encodes a value as deterministic JSON. + * + * @param mixed $value Value to encode. + * + * @throws JsonException if the value cannot be encoded. + * + * @return string Encoded JSON document. + */ + private static function encode(mixed $value): string + { + return json_encode($value, self::JSON_FLAGS); + } + + /** + * Returns the manifest file path. + * + * @return string Manifest file path. + */ + private function indexFile(): string + { + return "{$this->path}/" . self::INDEX_FILE; + } + + /** + * Creates the storage directory when it does not exist. + */ + private function initialize(): void + { + if (!is_dir($this->path)) { + @mkdir($this->path, $this->dirMode, true); + } + + if (!is_dir($this->path)) { + throw new StorageException( + "Unable to create debug data directory: {$this->path}", + ); + } + } + + /** + * Determines whether a tag can form a safe snapshot filename. + * + * @param string $tag Snapshot tag to validate. + * + * @return bool Whether the tag is safe for a filename. + */ + private static function isValidTag(string $tag): bool + { + return $tag !== 'index' + && preg_match('/\A(?!-?(?:0|[1-9][0-9]*)\z)[A-Za-z0-9_-][A-Za-z0-9._-]*\z/', $tag) === 1; + } + + /** + * Returns the manifest lock file path. + * + * @return string Manifest lock file path. + */ + private function lockFile(): string + { + return "{$this->path}/" . self::LOCK_FILE; + } + + /** + * Reads the manifest or returns `null` when persisted JSON is invalid. + * + * @return Manifest|null Hydrated manifest or `null` for invalid persisted JSON. + */ + private function readManifestFile(): Manifest|null + { + $raw = @file_get_contents($this->indexFile()); + + if ($raw === false || $raw === '') { + return new Manifest([]); + } + + try { + return Manifest::fromArray(self::decode($raw)); + } catch (Throwable) { + return null; + } + } + + /** + * Removes snapshot files whose tags no longer appear in the manifest. + * + * @param array $entries Retained manifest entries. + */ + private function removeStaleSnapshots(array $entries): void + { + $storedTags = []; + + $files = glob("{$this->path}/*.json"); + + foreach ($files === false ? [] : $files as $file) { + if ($file === $this->indexFile()) { + continue; + } + + $storedTags[] = pathinfo($file, PATHINFO_FILENAME); + } + + foreach (array_diff($storedTags, array_keys($entries)) as $tag) { + @unlink($this->snapshotFile($tag)); + } + } + + /** + * Returns the snapshot file path for a validated tag. + * + * @param string $tag Snapshot tag. + * + * @return string Snapshot file path. + */ + private function snapshotFile(string $tag): string + { + if (!self::isValidTag($tag)) { + throw new StorageException( + "Invalid debug snapshot tag: {$tag}", + ); + } + + return $this->path . "/{$tag}.json"; + } +} diff --git a/src/Storage/StorageException.php b/src/Storage/StorageException.php new file mode 100644 index 0000000..940b02d --- /dev/null +++ b/src/Storage/StorageException.php @@ -0,0 +1,12 @@ + 'debug', 3 => true]); + $hydrated = ArrayPayloadSnapshotFixture::fromArray($captured->jsonSerialize(), '$.fixture'); + + self::assertSame( + ['name' => 'debug', 3 => true], + $hydrated->data(), + 'Plain values must retain their keys and types.', + ); + self::assertSame( + $captured->jsonSerialize(), + $hydrated->jsonSerialize(), + 'Tagged payload must remain byte-for-byte equivalent.', + ); + } +} diff --git a/tests/Storage/DebugArrayTest.php b/tests/Storage/DebugArrayTest.php new file mode 100644 index 0000000..64dcf1b --- /dev/null +++ b/tests/Storage/DebugArrayTest.php @@ -0,0 +1,39 @@ + 1, 'b' => ['c' => true]]); + + self::assertSame( + ['a' => 1, 'b' => ['c' => true]], + DebugArray::fromArray($captured->jsonSerialize(), '$.panels.config.data')->values(), + 'Nested values must survive the round-trip.', + ); + } + + public function testThrowHydrationExceptionWhenTheTaggedValueIsNotAnArray(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.panels.config.data': expected a tagged array.", + ); + + DebugArray::fromArray(DebugValue::capture('a string')->jsonSerialize(), '$.panels.config.data'); + } +} diff --git a/tests/Storage/DebugSnapshotTest.php b/tests/Storage/DebugSnapshotTest.php new file mode 100644 index 0000000..1292273 --- /dev/null +++ b/tests/Storage/DebugSnapshotTest.php @@ -0,0 +1,100 @@ +summary(), + [], + [ + 'log' => PanelFailure::fromThrowable(PanelFailure::CAPTURE, new RuntimeException('boom')), + ], + ); + + $failures = $snapshot->jsonSerialize()['failures'] ?? null; + + self::assertIsArray( + $failures, + 'The failure collection must serialize as an array.', + ); + + $failure = $failures['log'] ?? null; + + self::assertIsArray( + $failure, + 'Serialized failures must not expose storage objects.', + ); + self::assertSame( + PanelFailure::CAPTURE, + $failure['stage'] ?? null, + 'The failure stage must be retained.', + ); + + $exception = $failure['exception'] ?? null; + + self::assertIsArray( + $exception, + 'The exception must serialize as an array.', + ); + self::assertSame( + 'boom', + $exception['message'] ?? null, + 'The serialized exception payload must be retained.', + ); + } + + public function testThrowHydrationExceptionWhenTheStorageVersionDoesNotMatch(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.version': expected storage version " . DebugSnapshot::VERSION . '.', + ); + + DebugSnapshot::fromArray( + [ + 'version' => DebugSnapshot::VERSION - 1, + 'summary' => [], + 'panels' => [], + 'failures' => [], + ], + ); + } + + /** + * Creates representative request metadata for snapshot tests. + * + * @return RequestSummary Representative request metadata. + */ + private function summary(): RequestSummary + { + return new RequestSummary( + tag: 'tag-1', + url: 'https://example.test/', + ajax: false, + method: 'GET', + ip: '127.0.0.1', + time: 1_700_000_000.0, + statusCode: 200, + sqlCount: 0, + excessiveCallersCount: 0, + mailCount: 0, + mailFiles: [], + processingTime: null, + peakMemory: null, + ); + } +} diff --git a/tests/Storage/DebugValueTest.php b/tests/Storage/DebugValueTest.php new file mode 100644 index 0000000..b153740 --- /dev/null +++ b/tests/Storage/DebugValueTest.php @@ -0,0 +1,617 @@ +type, + 'The value stays a tagged object.', + ); + self::assertStringContainsString( + 'Stringable@anonymous', + (string) $value->value, + 'The class name is the fallback.', + ); + } + + public function testCaptureLabelsAClosedResourceAsUnsupported(): void + { + $handle = fopen('php://memory', 'r'); + + self::assertIsResource( + $handle, + 'The fixture must open a stream.', + ); + + fclose($handle); + + $value = DebugValue::capture($handle); + + self::assertSame( + 'unsupported', + $value->type, + 'A closed resource is no longer a resource.', + ); + self::assertSame( + 'unknown-type', + $value->reason, + 'The reason must record why it was rejected.', + ); + } + + public function testCaptureLabelsAnOpenResourceWithItsType(): void + { + $handle = fopen('php://memory', 'r'); + + self::assertIsResource( + $handle, + 'The fixture must open a stream.', + ); + + $value = DebugValue::capture($handle); + + fclose($handle); + + self::assertSame( + 'resource', + $value->type, + 'An open resource keeps its tagged type.', + ); + self::assertSame( + 'stream', + $value->resourceType, + 'The resource type must be recorded.', + ); + self::assertSame( + '(resource: stream)', + $value->toDisplayValue(), + 'Display value must name the resource.', + ); + self::assertSame( + ['type' => 'resource', 'resourceType' => 'stream'], + $value->jsonSerialize(), + 'Serialized form must carry the resource type.', + ); + self::assertEquals( + $value, + DebugValue::fromArray($value->jsonSerialize()), + 'A resource must round-trip through hydration.', + ); + } + + public function testCaptureLabelsAThrowableWithItsMessage(): void + { + $value = DebugValue::capture(new RuntimeException('boom')); + + self::assertStringContainsString( + 'boom', + (string) $value->value, + 'The label must carry the message.', + ); + } + + public function testCaptureNormalizesBinaryArrayAndObjectKeys(): void + { + $binaryKey = "\xFF"; + $safeKey = '(binary: base64 /w==)'; + + $capturedArray = DebugValue::capture([$binaryKey => 'array value']); + + $arrayEntries = $capturedArray->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $arrayEntries, + 'Serialized array must contain tagged entries.', + ); + + $arrayEntry = $arrayEntries[0] ?? null; + + self::assertIsArray( + $arrayEntry, + 'First serialized array entry must retain its structure.', + ); + self::assertSame( + $safeKey, + $arrayEntry['key'] ?? null, + 'Binary array key must use a JSON-safe label.', + ); + self::assertJson( + json_encode($capturedArray, JSON_THROW_ON_ERROR), + 'Binary array key must not break JSON serialization.', + ); + + $object = new stdClass(); + + $object->{$binaryKey} = 'binary property'; + + $capturedObject = DebugValue::capture($object); + + $objectEntries = $capturedObject->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $objectEntries, + 'Serialized object must contain tagged entries.', + ); + + $objectEntry = $objectEntries[0] ?? null; + + self::assertIsArray( + $objectEntry, + 'First serialized object entry must retain its structure.', + ); + self::assertSame( + $safeKey, + $objectEntry['key'] ?? null, + 'Binary object key must use a JSON-safe label.', + ); + self::assertJson( + json_encode($capturedObject, JSON_THROW_ON_ERROR), + 'Binary object key must not break JSON serialization.', + ); + } + + public function testCaptureNormalizesInvalidUtf8StringableLabel(): void + { + $value = DebugValue::capture( + new class implements Stringable { + /** + * Returns a binary fixture label. + * + * @return string Binary fixture label. + */ + public function __toString(): string + { + return "\xB1\x31"; + } + }, + ); + + self::assertSame( + '(binary: base64 sTE=)', + $value->value, + 'A binary Stringable label must be represented as base64.', + ); + self::assertJson( + json_encode($value, JSON_THROW_ON_ERROR), + 'A binary Stringable label must remain JSON-safe.', + ); + } + + public function testCaptureNormalizesInvalidUtf8ThrowableMessage(): void + { + $value = DebugValue::capture(new RuntimeException("\xB1\x31")); + + self::assertSame( + RuntimeException::class . ': (binary: base64 sTE=)', + $value->value, + 'A binary throwable message must be represented as base64.', + ); + self::assertJson( + json_encode($value, JSON_THROW_ON_ERROR), + 'A binary throwable message must remain JSON-safe.', + ); + } + + public function testCapturePreservesTheArrayDepthBoundary(): void + { + $atLimit = 'leaf'; + $beyondLimit = 'leaf'; + + for ($depth = 0; $depth < 10; $depth++) { + $atLimit = [$atLimit]; + $beyondLimit = [$beyondLimit]; + } + + $beyondLimit = [$beyondLimit]; + + self::assertStringNotContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($atLimit)), + 'Depth ten must remain capturable.', + ); + self::assertStringContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($beyondLimit)), + 'Depth eleven must be truncated.', + ); + } + + public function testCapturePreservesTheObjectDepthBoundary(): void + { + $atLimit = 'leaf'; + $beyondLimit = 'leaf'; + + for ($depth = 0; $depth < 10; $depth++) { + $atLimit = (object) ['value' => $atLimit]; + $beyondLimit = (object) ['value' => $beyondLimit]; + } + + $beyondLimit = (object) ['value' => $beyondLimit]; + + self::assertStringNotContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($atLimit)), + 'Object depth ten must remain capturable.', + ); + self::assertStringContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($beyondLimit)), + 'Object depth eleven must be truncated.', + ); + } + + public function testCaptureStopsTraversingObjectBeyondTheNodeLimit(): void + { + $object = new stdClass(); + + for ($index = 0; $index < 10_050; $index++) { + $object->{"property-{$index}"} = $index; + } + + $value = DebugValue::capture($object); + $entries = $value->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $entries, + 'A captured object must contain serialized entries.', + ); + self::assertCount( + 10_000, + $entries, + 'Object traversal must stop after recording the node-limit marker.', + ); + self::assertStringContainsString( + 'SKIPPED over 10000 nodes', + $this->flatten($value), + 'Object values past the node budget must be truncated.', + ); + } + + public function testCaptureStringifiesAStringableObject(): void + { + $value = DebugValue::capture( + new class implements Stringable { + /** + * Returns the fixture label. + * + * @return string Fixture label. + */ + public function __toString(): string + { + return 'rendered'; + } + }, + ); + + self::assertSame( + 'rendered', + $value->value, + 'A Stringable must be labelled with its string form.', + ); + self::assertSame( + 'rendered', + $value->jsonSerialize()['value'] ?? null, + 'Serialized object must retain its label.', + ); + } + + public function testCaptureTreatsRepeatedObjectReferencesAsIndependentBranches(): void + { + $shared = (object) ['name' => 'shared']; + + $display = DebugValue::capture([$shared, $shared]) + ->toDisplayValue(); + + self::assertIsArray( + $display, + 'Top-level value must remain an array.', + ); + self::assertSame( + ['__class' => stdClass::class, 'name' => 'shared'], + $display[0] ?? null, + 'First branch must retain the shared object.', + ); + self::assertSame( + ['__class' => stdClass::class, 'name' => 'shared'], + $display[1] ?? null, + 'Second branch must not be mistaken for recursion.', + ); + } + + public function testCaptureTruncatesBeyondTheDepthLimit(): void + { + $deep = 'leaf'; + + for ($i = 0; $i < 12; $i++) { + $deep = [$deep]; + } + + self::assertStringContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($deep)), + 'Values nested past the depth limit must be truncated.', + ); + } + + public function testCaptureTruncatesBeyondTheNodeLimit(): void + { + $value = DebugValue::capture(range(1, 10_050)); + + $entries = $value->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $entries, + 'A captured array must contain serialized entries.', + ); + self::assertCount( + 10_000, + $entries, + 'Traversal must stop after recording the node-limit marker.', + ); + self::assertStringContainsString( + 'SKIPPED over 10000 nodes', + $this->flatten($value), + 'Values past the node budget must be truncated.', + ); + + $lastInBudget = $entries[9_998] ?? null; + $firstOutOfBudget = $entries[9_999] ?? null; + + self::assertIsArray( + $lastInBudget, + 'Last in-budget entry must retain its tagged structure.', + ); + self::assertIsArray( + $firstOutOfBudget, + 'First out-of-budget entry must retain its tagged structure.', + ); + self::assertSame( + ['type' => 'int', 'value' => 9_999], + $lastInBudget['value'] ?? null, + 'Last in-budget node must retain its value.', + ); + + $truncatedValue = $firstOutOfBudget['value'] ?? null; + + self::assertIsArray( + $truncatedValue, + 'Truncation marker must retain its tagged structure.', + ); + self::assertSame( + 'truncated', + $truncatedValue['type'] ?? null, + 'First out-of-budget node must carry the truncation marker.', + ); + } + + public function testCaptureUsesTheCanonicalClosureLabel(): void + { + $value = DebugValue::capture(static fn(): bool => true); + + self::assertSame( + '\\Closure', + $value->value, + 'Closure label must retain its canonical prefix.', + ); + } + + public function testRoundTripPreservesJsonSafeValuesAndLabelsUnsafeValues(): void + { + $object = new stdClass(); + + $object->name = 'debug'; + $object->self = $object; + + $value = DebugValue::capture( + [ + 'binary' => "\xB1\x31", + 'nan' => NAN, + 'positiveInfinity' => INF, + 'negativeInfinity' => -INF, + 'closure' => static fn(): bool => true, + 'object' => $object, + ], + ); + + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + $decoded = json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + + $display = DebugValue::fromArray($decoded)->toDisplayValue(); + + self::assertIsArray( + $display, + 'Top-level display value must remain an array.', + ); + self::assertIsString( + $display['binary'] ?? null, + 'Binary data must project to a label.', + ); + self::assertStringStartsWith( + '(binary: base64 ', + $display['binary'], + 'Binary label must identify its base64 representation.', + ); + self::assertSame( + 'NAN', + $display['nan'] ?? null, + 'NAN must retain its non-finite label.', + ); + self::assertSame( + 'INF', + $display['positiveInfinity'] ?? null, + 'Positive infinity must retain its non-finite label.', + ); + self::assertSame( + '-INF', + $display['negativeInfinity'] ?? null, + 'Negative infinity must retain its non-finite label.', + ); + self::assertSame( + ['__class' => Closure::class], + $display['closure'] ?? null, + 'Closure must project only its class marker.', + ); + + $capturedObject = $display['object'] ?? null; + + self::assertIsArray( + $capturedObject, + 'Captured object must project to an array.', + ); + self::assertSame( + stdClass::class, + $capturedObject['__class'] ?? null, + 'Object projection must retain its class marker.', + ); + self::assertSame( + 'debug', + $capturedObject['name'] ?? null, + 'Public object properties must retain their values.', + ); + self::assertSame( + stdClass::class, + $capturedObject['self'] ?? null, + 'Recursive reference must project to the object class.', + ); + } + + public function testThrowHydrationExceptionForAnEntryKeyThatDoesNotMatchItsKeyType(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '.key', + ); + + DebugValue::fromArray( + [ + 'type' => 'array', + 'entries' => [ + [ + 'keyType' => 'int', + 'key' => 'not-an-int', + 'value' => ['type' => 'null'], + ], + ], + ], + ); + } + + public function testThrowHydrationExceptionForAnUnknownSpecialFloat(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.value', + ); + + DebugValue::fromArray(['type' => 'special-float', 'value' => 'NOPE']); + } + + public function testThrowHydrationExceptionForAnUnsupportedBinaryEncoding(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.encoding', + ); + + DebugValue::fromArray(['type' => 'binary', 'encoding' => 'hex', 'data' => 'ff']); + } + + public function testThrowHydrationExceptionForFieldsThatDoNotBelongToTheTaggedType(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.value', + ); + + DebugValue::fromArray(['type' => 'null', 'value' => null]); + } + + public function testThrowHydrationExceptionForInvalidBinaryData(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.data', + ); + + DebugValue::fromArray(['type' => 'binary', 'encoding' => 'base64', 'data' => '*invalid*']); + } + + public function testThrowHydrationExceptionForUnknownFields(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.unexpected', + ); + + DebugValue::fromArray(['type' => 'null', 'unexpected' => true]); + } + + public function testToDisplayValuePreservesScalarAndDiagnosticTypes(): void + { + self::assertNull( + DebugValue::fromArray(['type' => 'null'])->toDisplayValue(), + 'Null tag must project to `null`.', + ); + self::assertSame( + 1.5, + DebugValue::fromArray(['type' => 'float', 'value' => 1.5])->toDisplayValue(), + 'Finite float must retain its value.', + ); + self::assertSame( + '*LIMIT*', + DebugValue::fromArray(['type' => 'truncated', 'value' => '*LIMIT*', 'reason' => 'size'])->toDisplayValue(), + 'Truncation label must remain visible.', + ); + self::assertSame( + '*UNKNOWN*', + DebugValue::fromArray(['type' => 'unsupported', 'value' => '*UNKNOWN*', 'reason' => 'fixture']) + ->toDisplayValue(), + 'Unsupported-value label must remain visible.', + ); + } + + /** + * Renders the tagged value as a `string` so truncation labels can be asserted regardless of nesting depth. + * + * @param DebugValue $value Tagged value to render. + * + * @return string JSON representation used by truncation assertions. + */ + private function flatten(DebugValue $value): string + { + return (string) json_encode($value->jsonSerialize()); + } +} diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php new file mode 100644 index 0000000..38fdfd6 --- /dev/null +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -0,0 +1,211 @@ +getClass(), + 'The hydrated exception must retain its original class.', + ); + self::assertSame( + 'outer failure', + $hydrated->getMessage(), + 'The hydrated exception must retain its original message.', + ); + self::assertSame( + 42, + $hydrated->getCode(), + 'The hydrated exception must retain its original code.', + ); + self::assertSame( + $throwable->getFile(), + $hydrated->getFile(), + 'The hydrated exception must retain its original file.', + ); + self::assertSame( + $throwable->getLine(), + $hydrated->getLine(), + 'The hydrated exception must retain its original line.', + ); + self::assertSame( + (string) $throwable, + (string) $hydrated, + 'The hydrated exception must retain its original string representation.', + ); + + $frame = $hydrated->getTrace()[0] ?? self::fail('Expected the helper call in the captured trace.'); + + self::assertSame( + ['namespace', 'short_class', 'class', 'type', 'function', 'file', 'line', 'args'], + array_keys($frame), + 'Trace projection must retain frame metadata alongside its arguments.', + ); + + $previous = $hydrated->getPrevious(); + + self::assertNotNull( + $previous, + 'The hydrated exception must retain its previous exception.', + ); + self::assertSame( + LogicException::class, + $previous->getClass(), + 'The hydrated previous exception must retain its original class.', + ); + self::assertSame( + 'inner failure', + $previous->getMessage(), + 'The hydrated previous exception must retain its original message.', + ); + self::assertSame( + 7, + $previous->getCode(), + 'The hydrated previous exception must retain its original code.', + ); + } + + public function testThrowableWhoseStringConversionFailsUsesClassAndMessage(): void + { + $throwable = new class ('conversion failure') extends RuntimeException { + /** + * Simulates a failing throwable string conversion. + * + * @return string Throwable text. + */ + public function __toString(): string + { + throw new LogicException('cannot stringify'); + } + }; + + $snapshot = ExceptionSnapshot::fromThrowable($throwable); + + self::assertSame( + $throwable::class . ': conversion failure', + (string) $snapshot, + 'A failed throwable string conversion must fall back to its class and message.', + ); + } + + public function testThrowableWithInvalidUtf8MessageRemainsJsonSafe(): void + { + $snapshot = ExceptionSnapshot::fromThrowable(new RuntimeException("\xB1\x31")); + + self::assertSame( + '(binary: base64 sTE=)', + $snapshot->getMessage(), + 'A binary throwable message must be represented as base64.', + ); + self::assertJson( + json_encode($snapshot, JSON_THROW_ON_ERROR), + 'A binary throwable message must not break snapshot serialization.', + ); + } + + public function testThrowHydrationExceptionForInvalidCodeType(): void + { + $payload = ExceptionSnapshot::fromThrowable(new RuntimeException('failure')) + ->jsonSerialize(); + + $payload['code'] = false; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.exception.code', + ); + + ExceptionSnapshot::fromArray($payload); + } + + public function testTraceProjectsArgumentsToPlainValues(): void + { + $snapshot = new ExceptionSnapshot( + class: RuntimeException::class, + message: 'failure', + code: 0, + file: __FILE__, + line: __LINE__, + trace: [ + [ + 'namespace' => __NAMESPACE__, + 'short_class' => 'ExceptionSnapshotTest', + 'class' => self::class, + 'type' => '->', + 'function' => 'fixture', + 'file' => __FILE__, + 'line' => __LINE__, + 'args' => DebugArray::capture(['trace argument']), + ], + ], + toString: 'failure', + previous: null, + ); + + $frame = $snapshot->getTrace()[0] ?? self::fail('Expected the deterministic fixture frame.'); + + self::assertSame( + ['trace argument'], + $frame['args'] ?? null, + 'Frame arguments must be restored to plain values.', + ); + } + + public function testTraceRetainsClassMetadata(): void + { + try { + $this->throwFromTrace(); + } catch (RuntimeException $throwable) { + $frame = ExceptionSnapshot::fromThrowable($throwable) + ->getTrace()[0] ?? self::fail('Expected the throwing method in the captured trace.'); + } + + self::assertSame( + __NAMESPACE__, + $frame['namespace'] ?? null, + 'Frame namespace must exclude the class name.', + ); + self::assertSame( + self::class, + $frame['class'] ?? null, + 'Frame class must retain its fully qualified name.', + ); + self::assertSame( + 'ExceptionSnapshotTest', + $frame['short_class'] ?? null, + 'Short class must exclude its namespace.', + ); + } + + /** + * Throws from a class method so trace metadata can be verified. + */ + private function throwFromTrace(): never + { + throw new RuntimeException('trace fixture'); + } +} diff --git a/tests/Storage/JsonTest.php b/tests/Storage/JsonTest.php new file mode 100644 index 0000000..4cf7d83 --- /dev/null +++ b/tests/Storage/JsonTest.php @@ -0,0 +1,34 @@ + $this->summary('tag-1')]); + + $restored = Manifest::fromArray($manifest->jsonSerialize()); + + self::assertSame( + ['tag-1'], + array_keys($restored->entries), + 'Entries must stay keyed by tag.', + ); + } + + public function testThrowHydrationExceptionWhenAnEntryTagDoesNotMatchItsKey(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.entries.tag-1.tag'", + ); + + Manifest::fromArray( + [ + 'version' => DebugSnapshot::VERSION, + 'entries' => ['tag-1' => $this->summary('other-tag')->jsonSerialize()], + ], + ); + } + + public function testThrowHydrationExceptionWhenTheStorageVersionDoesNotMatch(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.version': expected storage version " . DebugSnapshot::VERSION . '.', + ); + + Manifest::fromArray(['version' => DebugSnapshot::VERSION - 1, 'entries' => []]); + } + + /** + * Creates representative request metadata for a manifest entry. + * + * @param string $tag Request tag. + * + * @return RequestSummary Representative manifest entry. + */ + private function summary(string $tag): RequestSummary + { + return new RequestSummary( + tag: $tag, + url: 'https://example.test/', + ajax: false, + method: 'GET', + ip: '127.0.0.1', + time: 1_700_000_000.0, + statusCode: 200, + sqlCount: 0, + excessiveCallersCount: 0, + mailCount: 0, + mailFiles: [], + processingTime: null, + peakMemory: null, + ); + } +} diff --git a/tests/Storage/PanelFailureTest.php b/tests/Storage/PanelFailureTest.php new file mode 100644 index 0000000..798134a --- /dev/null +++ b/tests/Storage/PanelFailureTest.php @@ -0,0 +1,50 @@ +jsonSerialize(), '$.failures.log'); + + self::assertSame( + PanelFailure::HYDRATE, + $restored->stage, + 'The stage must round-trip.', + ); + self::assertSame( + 'boom', + $restored->exception->getMessage(), + 'The message must round-trip.', + ); + } + + public function testThrowHydrationExceptionForAnUnknownStage(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.failures.log.stage'", + ); + + PanelFailure::fromArray( + [ + 'stage' => 'render', + 'exception' => ExceptionSnapshot::fromThrowable(new RuntimeException('boom'))->jsonSerialize(), + ], + '$.failures.log', + ); + } +} diff --git a/tests/Storage/PayloadTest.php b/tests/Storage/PayloadTest.php new file mode 100644 index 0000000..92b541e --- /dev/null +++ b/tests/Storage/PayloadTest.php @@ -0,0 +1,202 @@ + 1, 'b' => 'two'], + Payload::object(['a' => 1, 'b' => 'two'])->all(), + 'Every decoded entry must survive.', + ); + } + + public function testNullableNumberReturnsIntegerInputAsFloat(): void + { + self::assertSame( + 7.0, + Payload::object(['duration' => 7])->nullableNumber('duration'), + 'Integer JSON numbers must satisfy the nullable float contract.', + ); + } + + public function testObjectAcceptsAnEmptyArrayAsAnEmptyObject(): void + { + self::assertSame( + [], + Payload::object([])->all(), + 'An empty JSON object decodes to an empty array.', + ); + } + + public function testRowsValidatesEveryElementAsAnObject(): void + { + self::assertSame( + [['file' => 'a.php'], ['file' => 'b.php']], + Payload::object(['trace' => [['file' => 'a.php'], ['file' => 'b.php']]])->rows('trace'), + 'Each element must round-trip as a string-keyed map.', + ); + } + + public function testThrowHydrationExceptionForAnObjectWithIntegerKeys(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$': expected an object with string keys.", + ); + + Payload::object([1 => 'a']); + } + + public function testThrowHydrationExceptionForANonBooleanValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.flag': expected a boolean.", + ); + + Payload::object(['flag' => 1])->bool('flag'); + } + + public function testThrowHydrationExceptionForANonFiniteNullableNumber(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.duration': expected a number or null.", + ); + + Payload::object(['duration' => json_decode('-1e400', flags: JSON_THROW_ON_ERROR)])->nullableNumber('duration'); + } + + public function testThrowHydrationExceptionForANonFiniteNumber(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.time': expected a number.", + ); + + Payload::object(['time' => json_decode('1e400', flags: JSON_THROW_ON_ERROR)])->number('time'); + } + + public function testThrowHydrationExceptionForANonIntegerValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.count': expected an integer.", + ); + + Payload::object(['count' => '7'])->int('count'); + } + + public function testThrowHydrationExceptionForANonListValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.entries': expected a list.", + ); + + Payload::object(['entries' => ['a' => 1]])->list('entries'); + } + + public function testThrowHydrationExceptionForANonNumberValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.time': expected a number.", + ); + + Payload::object(['time' => '1.5'])->number('time'); + } + + public function testThrowHydrationExceptionForANonStringValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.name': expected a string.", + ); + + Payload::object(['name' => 42])->string('name'); + } + + public function testThrowHydrationExceptionForANullableIntegerCarryingAString(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.line': expected an integer or null.", + ); + + Payload::object(['line' => '7'])->nullableInt('line'); + } + + public function testThrowHydrationExceptionForANullableNumberCarryingAString(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.duration': expected a number or null.", + ); + + Payload::object(['duration' => '1.5'])->nullableNumber('duration'); + } + + public function testThrowHydrationExceptionForANullableStringCarryingAnInteger(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.action': expected a string or null.", + ); + + Payload::object(['action' => 42])->nullableString('action'); + } + + public function testThrowHydrationExceptionForAnUndeclaredField(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.extra': expected a declared field.", + ); + + Payload::object(['name' => 'a', 'extra' => 1])->shape(['name']); + } + + public function testThrowHydrationExceptionForAValueThatIsNotAnObject(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$': expected an object.", + ); + + Payload::object(['a', 'b']); + } + + public function testThrowHydrationExceptionWhenAMissingKeyIsRead(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.absent': expected a required field.", + ); + + Payload::object([])->raw('absent'); + } + + public function testThrowHydrationExceptionWhenARequiredFieldIsMissing(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.name': expected a required field.", + ); + + Payload::object([])->shape(['name']); + } +} diff --git a/tests/Storage/RequestSummaryTest.php b/tests/Storage/RequestSummaryTest.php new file mode 100644 index 0000000..e3b2e86 --- /dev/null +++ b/tests/Storage/RequestSummaryTest.php @@ -0,0 +1,114 @@ +payload()); + + self::assertSame( + 200, + $summary->statusCode, + 'Status code must remain an integer.', + ); + self::assertSame( + 1_700_000_000.0, + $summary->time, + 'Request timestamp must remain a float.', + ); + self::assertFalse( + $summary->ajax, + 'Synchronous request flag must remain `false`.', + ); + } + + public function testThrowHydrationExceptionForNumericString(): void + { + $payload = $this->payload(); + + $payload['statusCode'] = '200'; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.summary.statusCode', + ); + + RequestSummary::fromArray($payload); + } + + public function testThrowHydrationExceptionForUnknownField(): void + { + $payload = $this->payload(); + + $payload['unexpected'] = true; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.summary.unexpected', + ); + + RequestSummary::fromArray($payload); + } + + public function testThrowHydrationExceptionWhenAMailFileEntryIsNotAString(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.summary.mailFiles[1]'", + ); + + RequestSummary::fromArray( + [ + 'tag' => 'tag-1', + 'url' => 'https://example.test/', + 'ajax' => false, + 'method' => 'GET', + 'ip' => '127.0.0.1', + 'time' => 1_700_000_000.0, + 'statusCode' => 200, + 'sqlCount' => 0, + 'excessiveCallersCount' => 0, + 'mailCount' => 2, + 'mailFiles' => ['a.eml', 42], + 'processingTime' => null, + 'peakMemory' => null, + ], + ); + } + + /** + * Returns representative decoded request metadata. + * + * @return array Representative decoded request metadata. + */ + private function payload(): array + { + return [ + 'tag' => 'tag-1', + 'url' => 'https://example.test/', + 'ajax' => false, + 'method' => 'GET', + 'ip' => '127.0.0.1', + 'time' => 1_700_000_000.0, + 'statusCode' => 200, + 'sqlCount' => 0, + 'excessiveCallersCount' => 0, + 'mailCount' => 0, + 'mailFiles' => [], + 'processingTime' => null, + 'peakMemory' => null, + ]; + } +} diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php new file mode 100644 index 0000000..3411359 --- /dev/null +++ b/tests/Storage/SnapshotStoreTest.php @@ -0,0 +1,767 @@ +store()->clear(); + + self::assertDirectoryExists( + $this->path, + 'Storage directory must be created before locking.', + ); + self::assertFileExists( + "{$this->path}/index.lock", + 'Empty storage must retain its lock file.', + ); + } + + public function testClearRemovesSnapshotsAndManifest(): void + { + $store = $this->store(); + + $summary = $this->summary('current', 1_700_000_000.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + + $store->clear(); + + self::assertNull( + $store->readSnapshot('current'), + 'Cleared snapshot must read as `null`.', + ); + self::assertSame( + [], + $store->loadManifest(), + 'Cleared manifest must contain no entries.', + ); + self::assertFileExists( + "{$this->path}/index.lock", + 'Clear must preserve the shared lock file.', + ); + } + + public function testGarbageCollectionReportsEveryRemovedSummary(): void + { + $store = $this->store(); + + for ($index = 0; $index < 3; $index++) { + $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + $current = $this->summary('current', 1_700_000_003.0); + + $removed = $store->writeSnapshot( + new DebugSnapshot($current, [], []), + 1, + ); + + self::assertSame( + ['tag-0', 'tag-1', 'tag-2'], + array_map(static fn(RequestSummary $summary): string => $summary->tag, $removed), + 'Eviction report must include every discarded summary.', + ); + self::assertSame( + ['current'], + array_keys($store->loadManifest()), + 'Manifest must retain only the newest request.', + ); + } + + public function testHistorySizeIsStrictMaximum(): void + { + $store = $this->store(); + + for ($index = 0; $index < 3; $index++) { + $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 2, + ); + } + + self::assertSame( + ['tag-2', 'tag-1'], + array_keys($store->loadManifest()), + 'Manifest must contain only the configured number of entries.', + ); + self::assertFileDoesNotExist( + "{$this->path}/tag-0.json", + 'Oldest snapshot must be removed at the retention boundary.', + ); + } + + public function testHistorySizeZeroPersistsNoSnapshots(): void + { + $store = $this->store(); + + $summary = $this->summary('current', 1_700_000_000.0); + + $removed = $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 0, + ); + + self::assertSame( + [$summary], + $removed, + 'Discarded request summary must be reported for dependent cleanup.', + ); + self::assertSame( + [], + $store->loadManifest(), + 'Manifest must remain empty.', + ); + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'Discarded snapshot must not leave an orphan file.', + ); + } + + public function testInvalidJsonIsRejectedWithoutExecutingPayloads(): void + { + mkdir($this->path, recursive: true); + file_put_contents("{$this->path}/invalid.json", '{invalid'); + + self::assertNull( + $this->store()->readSnapshot('invalid'), + 'Malformed JSON must read as `null`.', + ); + } + + public function testInvalidManifestResetsStaleSnapshots(): void + { + mkdir($this->path, recursive: true); + + file_put_contents("{$this->path}/index.json", '{invalid'); + file_put_contents("{$this->path}/stale.json", '{}'); + + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + + self::assertFileDoesNotExist( + "{$this->path}/stale.json", + 'Reset manifest must discard snapshots it cannot reference.', + ); + self::assertFileExists( + "{$this->path}/current.json", + 'Replacement snapshot must remain stored.', + ); + self::assertSame( + ['current'], + array_keys($this->store()->loadManifest()), + 'Replacement manifest must contain the new request.', + ); + } + + public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): void + { + $store = $this->store(); + + $summary = $this->summary('tag-1', 1_700_000_000.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'fopen', + [], + false, + true, + ); + + self::assertSame( + [], + $store->loadManifest(), + 'An unopenable lock file must yield an empty manifest instead of throwing.', + ); + } + + public function testLoadManifestReturnsNothingWhenTheSharedLockCannotBeAcquired(): void + { + $store = $this->store(); + + $summary = $this->summary('tag-1', 1_700_000_000.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'flock', + [], + false, + true, + ); + + self::assertSame( + [], + $store->loadManifest(), + 'An unavailable shared lock must yield an empty manifest.', + ); + } + + public function testReadRejectsTagThatEscapesTheStorageDirectory(): void + { + self::assertNull( + $this->store()->readSnapshot('../outside'), + 'Unsafe read tag must yield `null`.', + ); + } + + public function testReadSnapshotReturnsNullForATagThatWasNeverWritten(): void + { + mkdir($this->path, recursive: true); + + self::assertNull( + $this->store()->readSnapshot('never-written'), + 'A missing snapshot file must read back as `null`.', + ); + } + + public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void + { + $store = $this->store(); + + $kept = $this->summary('kept', 1_700_000_000.0); + + $store->writeSnapshot( + new DebugSnapshot($kept, [], []), + 2, + ); + + file_put_contents("{$this->path}/orphan.json", '{}'); + + for ($index = 0; $index < 13; $index++) { + $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 2, + ); + } + + self::assertFileDoesNotExist( + "{$this->path}/orphan.json", + 'A snapshot with no manifest entry must be swept.', + ); + self::assertFileExists( + "{$this->path}/tag-12.json", + 'Newest retained snapshot must survive stale-file cleanup.', + ); + self::assertFileExists( + "{$this->path}/tag-11.json", + 'Second retained snapshot must survive stale-file cleanup.', + ); + } + + public function testRewrittenSnapshotBecomesNewestManifestEntry(): void + { + $store = $this->store(); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('first', 1_700_000_000.0), [], []), + 2, + ); + $store->writeSnapshot( + new DebugSnapshot($this->summary('second', 1_700_000_001.0), [], []), + 2, + ); + $store->writeSnapshot( + new DebugSnapshot($this->summary('first', 1_700_000_002.0), [], []), + 2, + ); + + $removed = $store->writeSnapshot( + new DebugSnapshot($this->summary('third', 1_700_000_003.0), [], []), + 2, + ); + + self::assertSame( + ['second'], + array_map(static fn(RequestSummary $summary): string => $summary->tag, $removed), + 'Eviction must target the least recently written snapshot.', + ); + self::assertSame( + ['third', 'first'], + array_keys($store->loadManifest()), + 'Manifest order must reflect the latest write for each tag.', + ); + } + + public function testSnapshotAndManifestRoundTripThroughJson(): void + { + $store = $this->store(); + + $older = $this->summary('older', 1_700_000_000.0); + $newer = $this->summary('newer', 1_700_000_001.0); + + $store->writeSnapshot( + new DebugSnapshot($older, [], []), + 10, + ); + $store->writeSnapshot( + new DebugSnapshot($newer, ['panel' => ['value' => 1]], []), + 10, + ); + + $snapshot = $store->readSnapshot('newer'); + + self::assertNotNull( + $snapshot, + 'Persisted snapshot must remain readable.', + ); + self::assertSame( + 'newer', + $snapshot->summary->tag, + 'Request tag must survive persistence.', + ); + self::assertSame( + ['value' => 1], + $snapshot->panels['panel'] ?? null, + 'Panel payload must survive persistence.', + ); + self::assertSame( + ['newer', 'older'], + array_keys($store->loadManifest()), + 'Manifest entries must be ordered newest first.', + ); + } + + /** + * @param string $tag Leading-dot tag. + */ + #[TestWith(['.'])] + #[TestWith(['.hidden'])] + public function testThrowStorageExceptionForLeadingDotTag(string $tag): void + { + $summary = $this->summary($tag, 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot tag: {$tag}", + ); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionForNegativeHistorySize(): void + { + $store = $this->store(); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Invalid debug history size: -1', + ); + + try { + $summary = $this->summary('current', 1_700_000_000.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + -1, + ); + } finally { + self::assertDirectoryDoesNotExist( + $this->path, + 'Invalid history size must be rejected before storage initialization.', + ); + } + } + + /** + * @param string $tag Numeric tag. + */ + #[TestWith(['0'])] + #[TestWith(['7'])] + public function testThrowStorageExceptionForNumericTag(string $tag): void + { + $summary = $this->summary($tag, 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot tag: {$tag}", + ); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionForReservedManifestTag(): void + { + $summary = $this->summary('index', 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Invalid debug snapshot tag: index', + ); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionForTagThatEscapesTheStorageDirectory(): void + { + $summary = $this->summary('../outside', 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Invalid debug snapshot tag: ../outside', + ); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionWhenClearCannotAcquireTheExclusiveLock(): void + { + $store = $this->store(); + + $summary = $this->summary('current', 1_700_000_000.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'flock', + [], + false, + true, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to acquire debug data lock', + ); + + try { + $store->clear(); + } finally { + self::assertFileExists( + "{$this->path}/current.json", + 'Failed lock acquisition must leave the snapshot intact.', + ); + } + } + + public function testThrowStorageExceptionWhenStoragePathCannotBeCreated(): void + { + file_put_contents($this->path, 'not a directory'); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Unable to create debug data directory: {$this->path}", + ); + + try { + $this->store()->clear(); + } finally { + unlink($this->path); + } + } + + public function testThrowStorageExceptionWhenTheManifestCannotBeWrittenAtHistoryLimit(): void + { + $store = $this->store(); + + $older = $this->summary('older', 1_700_000_000.0); + $newer = $this->summary('newer', 1_700_000_001.0); + + $store->writeSnapshot( + new DebugSnapshot($older, [], []), + 2, + ); + $store->writeSnapshot( + new DebugSnapshot($newer, [], []), + 2, + ); + + $temporaryFileCalls = 0; + + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'tempnam', + [$this->path, '.debug-'], + static function (string $directory, string $prefix) use (&$temporaryFileCalls): string|false { + return ++$temporaryFileCalls === 2 ? false : tempnam($directory, $prefix); + }, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); + + try { + $summary = $this->summary('blocked', 1_700_000_002.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 2, + ); + } finally { + self::assertSame( + ['newer', 'older'], + array_keys($store->loadManifest()), + 'Failed manifest commit must preserve the previous manifest.', + ); + self::assertNotNull( + $store->readSnapshot('older'), + 'Oldest retained snapshot must remain readable.', + ); + } + } + + public function testThrowStorageExceptionWhenTheSnapshotCannotBeMovedIntoPlace(): void + { + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'rename', + [], + false, + true, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to replace debug data file', + ); + + $summary = $this->summary('blocked', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeCreated(): void + { + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'tempnam', + [], + false, + true, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); + + $summary = $this->summary('blocked', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeWritten(): void + { + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'file_put_contents', + [], + false, + true, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); + + $summary = $this->summary('blocked', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + public function testThrowStorageExceptionWhenWriteCannotAcquireTheExclusiveLock(): void + { + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'flock', + [], + false, + true, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to acquire debug data lock', + ); + + try { + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } finally { + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'Failed lock acquisition must not write the snapshot.', + ); + self::assertFileDoesNotExist( + "{$this->path}/index.json", + 'Failed lock acquisition must not write the manifest.', + ); + } + } + + public function testThrowStorageExceptionWhenWriteCannotOpenTheLockFile(): void + { + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'fopen', + [], + false, + true, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to open debug data lock file', + ); + + try { + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } finally { + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'An unopenable lock file must prevent snapshot persistence.', + ); + } + } + + /** + * Creates an isolated temporary storage path. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->path = sys_get_temp_dir() . '/yii-debug-storage-' . uniqid('', true); + } + + /** + * Removes the temporary storage directory after each test. + */ + protected function tearDown(): void + { + $this->removeDirectory($this->path); + + parent::tearDown(); + } + + /** + * Removes a directory tree created by a test. + * + * @param string $path Directory path to remove. + */ + private function removeDirectory(string $path): void + { + $files = glob($path . '/*'); + + foreach ($files === false ? [] : $files as $file) { + is_dir($file) ? $this->removeDirectory($file) : unlink($file); + } + + if (is_dir($path)) { + rmdir($path); + } + } + + /** + * Creates a store for the isolated temporary path. + * + * @return SnapshotStore Store configured for the current test. + */ + private function store(): SnapshotStore + { + return new SnapshotStore($this->path, 0o777, null); + } + + /** + * Creates representative request metadata for storage tests. + * + * @param string $tag Request tag. + * @param float $time Request start timestamp. + * @return RequestSummary Representative request metadata. + */ + private function summary(string $tag, float $time): RequestSummary + { + return new RequestSummary( + tag: $tag, + url: 'https://example.test/', + ajax: false, + method: 'GET', + ip: '127.0.0.1', + time: $time, + statusCode: 200, + sqlCount: 0, + excessiveCallersCount: 0, + mailCount: 0, + mailFiles: [], + processingTime: null, + peakMemory: null, + ); + } +} diff --git a/tests/Support/ArrayPayloadSnapshotFixture.php b/tests/Support/ArrayPayloadSnapshotFixture.php new file mode 100644 index 0000000..2a9b6d1 --- /dev/null +++ b/tests/Support/ArrayPayloadSnapshotFixture.php @@ -0,0 +1,35 @@ + Restored fixture payload. + */ + public function data(): array + { + return $this->values(); + } + + /** + * Returns the fixture payload key. + * + * @return string Fixture payload key. + */ + protected static function payloadKey(): string + { + return 'values'; + } +} diff --git a/tests/Support/MockerExtension.php b/tests/Support/MockerExtension.php new file mode 100644 index 0000000..e183011 --- /dev/null +++ b/tests/Support/MockerExtension.php @@ -0,0 +1,83 @@ +registerSubscribers( + new class implements StartedSubscriber { + /** + * Loads function mocks when the test suite starts. + * + * @param Started $event Test suite event. + */ + public function notify(Started $event): void + { + MockerExtension::load(); + } + }, + new class implements PreparationStartedSubscriber { + /** + * Resets function mocks before each test. + * + * @param PreparationStarted $event Test preparation event. + */ + public function notify(PreparationStarted $event): void + { + MockerState::resetState(); + MockerExtension::resetDefaults(); + } + }, + ); + } + + /** + * Loads filesystem function stubs for the storage namespace. + */ + public static function load(): void + { + $mocks = []; + + foreach (['file_put_contents', 'flock', 'fopen', 'mkdir', 'rename', 'tempnam'] as $name) { + $mocks[] = [ + 'namespace' => 'PHPForge\Debug\Storage', + 'name' => $name, + ]; + } + + (new Mocker(stubPath: __DIR__ . '/mocker-stubs.php'))->load($mocks); + + MockerState::saveState(); + } + + /** + * Clears mock defaults between tests. + */ + public static function resetDefaults(): void + { + $defaults = (new ReflectionClass(MockerState::class))->getProperty('defaults'); + + $defaults->setValue(null, []); + } +} diff --git a/tests/Support/mocker-stubs.php b/tests/Support/mocker-stubs.php new file mode 100644 index 0000000..0a916c6 --- /dev/null +++ b/tests/Support/mocker-stubs.php @@ -0,0 +1,8 @@ + + */ +return [];