diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000000..398c9f9ae1 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,40 @@ +name: PHPUnit + +on: + push: + branches: + - release-3.0 + pull_request: + +jobs: + phpunit: + name: Unit tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 #2.32.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf #4.2.2 + with: + path: vendor + key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-php${{ matrix.php }}- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install --prefer-dist --no-progress --ansi + + - name: Run the unit tests + run: vendor/bin/phpunit --no-coverage --colors=always diff --git a/.gitignore b/.gitignore index 8a574e9b8d..1dfe550f48 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ vendor/ .phplint-cache .phplint.cache composer.phar + +# PHPUnit +.phpunit.cache/ +.phpunit.result.cache diff --git a/AGENTS.md b/AGENTS.md index 8358d28345..1996a79833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,12 +110,83 @@ param, throws, return. ## Verifying a change -**There is no test suite.** No PHPUnit, no `tests/` directory, nothing in the history. -CI only proves that the code parses (`phplint` on 8.4 and 8.5) and is formatted -correctly. It never executes SMF. Do not assume green checks mean a change works. +### Tests -So verify by running the forum. The repository ships a Docker environment, documented -in full in `.docker/README.md`: +There is a unit test suite. It is small and deliberately narrow, but where it reaches, +it is the only automated proof that a change does what it claims: + +```bash +composer test # or: vendor/bin/phpunit +``` + +CI runs it on every pull request, and on pushes to `release-3.0`, via +`.github/workflows/phpunit.yml`. Feature branches are only checked once they are in a PR, +so run it locally. + +**The expectation: if the code you touched is reachable from this suite, your change +adds or updates a test in the same commit.** A bug fix lands as a regression test that +fails before the fix and passes after it, with a comment saying what went wrong — see +`SapiTest::testAPlainByteCountKeepsItsLastDigit()` for the shape. When the code is not +reachable, say so explicitly in the PR description rather than leaving it unsaid; do not +contort production code, add mocks or fake a database to force something under test. + +#### When a test is possible + +`tests/bootstrap.php` defines the constants `index.php` would define, points the +autoloader at `Sources/` and sets `Config::$boarddir`, `$sourcedir`, `$packagesdir`, +`$languagesdir`, `$cachedir` and `$language`. That is all. No `Settings.php`, no +database, no request. Within those limits the following are all testable, and each has a +worked example in `tests/Unit/`: + +- **Pure and static helpers**: `Utils::buildRegex()`, `Sapi::memoryReturnBytes()`, + `Security::hashPassword()`. Cheap to cover with a `#[DataProvider]`. +- **Value objects that parse or normalise a string**: `IP`, `Url`, `Uuid`, + `TimeInterval`, `Punycode`. Construct one and assert on the result. +- **Class-level behaviour that needs no state**: late static binding, shared statics, + what `Foo::load()` returns. `ActionTraitTest` is entirely this. +- **Protected and private helpers**, through `ReflectionMethod`, when the public entry + point around them needs a database but the helper itself does not + (`CreatePostNotifyTest::getTimeOffset()`). +- **Code that reads a few `Config::$modSettings` keys.** Set them in `setUp()` and + `unset()` them in `tearDown()`. PHPUnit does not reset SMF's statics between tests, so + a key left behind leaks into every test that follows. +- **Anything that only needs the language or Unicode data files**, since the bootstrap + sets the paths they look in. + +#### When it is not + +- Anything calling `Db::$db` — there is no connection, and faking one is not worth it. +- Anything reading `User::$me`, the session, `$_GET`/`$_POST`/`$_SERVER`, or expecting a + loaded theme or `Utils::$context`. +- Anything that emits output or sends headers. `beStrictAboutOutputDuringTests` is on, so + a stray `echo` fails the test rather than being swallowed. + +`failOnRisky` and `failOnWarning` are on as well: a test that asserts nothing is a +failure, not a pass. + +#### Writing one + +`tests/Unit/Test.php`, namespace `SMF\Tests\Unit`, `declare(strict_types=1)`, +extending `PHPUnit\Framework\TestCase`, with `#[CoversClass]` (or `#[CoversTrait]` for a +trait) on the class. Name the test after the behaviour, not the method — +`testItNormalisesIPv6ToItsShortestForm()`, not `testConstruct()`. New directories need +the usual `index.php` stub. + +The code style rules apply to tests too, so run `composer lint-fix` on them. Two +consequences of the fixer worth knowing before you fight it: + +- Data providers are `public static`, so `ordered_class_elements` moves them *below* the + public test methods, into their own `Public static methods` banner. +- The `SMF/section_comments` fixer inserts a banner between an attribute and the method + it belongs to. Do not let a method carrying `#[DataProvider]` be the first one in its + group; `CreatePostNotifyTest` carries a note about this. + +### Running the forum + +The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted. +So a fully green PR still tells you very little about whether a change works. Verify by +running the forum. The repository ships a Docker environment, documented in full in +`.docker/README.md`: ```bash docker compose up -d --build @@ -146,10 +217,6 @@ docker compose exec postgres psql -U smf -d smf -c 'SELECT * FROM smf_log_errors `smf_log_errors` is the first place to look. Many failures are recorded there rather than shown, especially anything in a background task. -Some code is reachable with only the autoloader plus the constants that `index.php` -defines, which is enough to exercise pure helpers without a database. Anything that -touches `User::$me` or `Db::$db` needs a real request or fixtures. - ## Things that bite in this codebase - **Typed properties with no default throw when read before assignment.** Several are diff --git a/Sources/Url.php b/Sources/Url.php index 985a3a8c18..c6361240ea 100644 --- a/Sources/Url.php +++ b/Sources/Url.php @@ -631,6 +631,9 @@ public function isWebsite(): bool /** * Check if this URL uses one of the specified schemes. * + * Scheme names are case insensitive, per RFC 3986, section 3.1, and this + * class does not normalize them, so both sides are folded before comparing. + * * @param string|string[] $scheme Schemes to check. * @return bool Whether the URL matches a scheme. */ diff --git a/composer.json b/composer.json index c08fcb3848..edb9014a7a 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,11 @@ "prefer-stable": true, "require-dev": { "simplemachines/build-tools": "dev-release-3.0", - "friendsofphp/php-cs-fixer": "^3.95" + "friendsofphp/php-cs-fixer": "^3.95", + "phpunit/phpunit": "^13.1" }, "scripts": { + "test": "phpunit --no-coverage", "lint": "php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "lint-fix": "php-cs-fixer fix -v --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "post-install-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php", diff --git a/composer.lock b/composer.lock index 0f762a87ed..c092fa442b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5741c6f1fd0055c11f7cc35751f40b2b", + "content-hash": "50c2bac5d85b523f36379e484a555b9d", "packages": [ { "name": "bjeavons/zxcvbn-php", @@ -1025,6 +1025,123 @@ ], "time": "2026-07-30T15:46:02+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, { "name": "overtrue/phplint", "version": "9.0.4", @@ -1109,705 +1226,2335 @@ "time": "2023-02-23T15:46:09+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": "^7.2 || ^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "phpunit/php-code-coverage", + "version": "14.2.4", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/048a5c12bdb4580f4767ce2761793a16b170fbe4", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "log", - "psr", - "psr-3" + "coverage", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.4" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-07-30T17:01:07+00:00" }, { - "name": "react/cache", - "version": "v1.2.0", + "name": "phpunit/php-file-iterator", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Cache\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async, Promise-based cache interface for ReactPHP", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "cache", - "caching", - "promise", - "reactphp" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" - }, + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.2.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5d2afe181339a56348ef9a80fa7eb806b7eae508", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.2-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.6" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T14:00:09+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-08-01T04:27:14+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:06:45+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, { - "name": "react/child-process", - "version": "v0.6.7", + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-07-13T11:35:11+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.4" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/socket": "^1.16", - "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" }, "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ - "event-driven", - "process", - "reactphp" + "global state" ], "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2025-12-23T15:25:20+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { - "name": "react/dns", - "version": "v1.14.0", + "name": "sebastian/lines-of-code", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^13.2.4" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async DNS resolver for ReactPHP", - "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" - ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-11-18T19:34:28+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { - "name": "react/event-loop", - "version": "v1.6.0", + "name": "sebastian/object-enumerator", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", - "keywords": [ - "asynchronous", - "event-loop" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2025-11-17T20:46:25+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "sebastian/object-reflector", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": ">=8.4" }, "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { - "name": "react/socket", - "version": "v1.17.0", + "name": "sebastian/recursion-context", + "version": "8.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^13.2.6" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.17.0" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2025-11-19T20:47:34+00:00" + "time": "2026-08-03T05:58:12+00:00" }, { - "name": "react/stream", - "version": "v1.4.0", + "name": "sebastian/type", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" + "php": ">=8.4" }, "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "phpunit/phpunit": "^13.1.10" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-06-11T12:45:25+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { - "name": "sebastian/diff", - "version": "9.0.0", + "name": "sebastian/version", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { "php": ">=8.4" }, - "require-dev": { - "phpunit/phpunit": "^13.2", - "symfony/process": "^7.4.13" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "9.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1822,25 +3569,16 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { @@ -1856,11 +3594,11 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", "type": "tidelift" } ], - "time": "2026-06-05T03:04:51+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "simplemachines/build-tools", @@ -1888,6 +3626,58 @@ }, "time": "2026-05-27T23:44:16+00:00" }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, { "name": "symfony/cache", "version": "v6.4.43", @@ -3570,6 +5360,56 @@ } ], "time": "2026-07-20T15:18:49+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000000..951db14dd1 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + + + Sources + + + diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php new file mode 100644 index 0000000000..c194babe40 --- /dev/null +++ b/tests/Unit/ActionTraitTest.php @@ -0,0 +1,66 @@ +assertInstanceOf(Login2::class, Login2::load()); + $this->assertInstanceOf(Logout::class, Logout::load()); + } + + public function testLoadIsStillCorrectWhenTheParentWasLoadedFirst(): void + { + // $obj is a static property declared in the trait, so it is shared with + // every descendant that does not redeclare it. Loading the parent first + // used to leave the parent's instance in the slot the child reads. + Login2::load(); + + $this->assertInstanceOf(Logout::class, Logout::load()); + $this->assertInstanceOf(Login::class, Login::load()); + } + + public function testLoadIsStillCorrectWhenTheChildWasLoadedFirst(): void + { + Logout::load(); + + $this->assertInstanceOf(Login2::class, Login2::load()); + } + + public function testTheSameProblemInAnUnrelatedHierarchy(): void + { + // Eleven action classes extend another action and none redeclare $obj, + // so this is not specific to the login hierarchy. Notify is abstract and + // so cannot be loaded at all; Agreement and Unread are the other pairs + // with a concrete parent. + Agreement::load(); + Unread::load(); + + $this->assertInstanceOf(AgreementAccept::class, AgreementAccept::load()); + $this->assertInstanceOf(UnreadReplies::class, UnreadReplies::load()); + } + + public function testLoadCachesTheInstanceItReturns(): void + { + $this->assertSame(Login2::load(), Login2::load()); + } +} diff --git a/tests/Unit/CreatePostNotifyTest.php b/tests/Unit/CreatePostNotifyTest.php new file mode 100644 index 0000000000..8bdf1a515a --- /dev/null +++ b/tests/Unit/CreatePostNotifyTest.php @@ -0,0 +1,80 @@ +assertSame(3.0, $this->getTimeOffset('Etc/GMT-5')); + } + + // Note: the section banner above must not be the first thing in this group when + // the first member carries an attribute. The SMF/section_comments fixer inserts + // the banner between the attribute and its method, which is why the data provider + // case is second rather than first. + #[DataProvider('timezoneProvider')] + public function testGetTimeOffset(string $timezone, float $expected): void + { + $this->assertSame($expected, $this->getTimeOffset($timezone)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function timezoneProvider(): array + { + return [ + 'UTC is no offset' => ['UTC', 0.0], + 'whole hour' => ['Etc/GMT-5', 5.0], + 'negative whole hour' => ['Etc/GMT+5', -5.0], + 'half hour is not truncated' => ['Asia/Kolkata', 5.5], + 'quarter hour is not truncated' => ['Asia/Kathmandu', 5.75], + 'empty time zone falls back to zero' => ['', 0.0], + ]; + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + // The offset is relative to the forum's own time zone, so pin it. + Config::$modSettings['default_timezone'] = 'UTC'; + } + + protected function tearDown(): void + { + unset(Config::$modSettings['default_timezone']); + } + + /** + * Calls the protected helper under test. + */ + private function getTimeOffset(string $timezone): float + { + $method = new \ReflectionMethod(CreatePost_Notify::class, 'getTimeOffset'); + + return $method->invoke(null, $timezone); + } +} diff --git a/tests/Unit/IPTest.php b/tests/Unit/IPTest.php new file mode 100644 index 0000000000..581da0c17c --- /dev/null +++ b/tests/Unit/IPTest.php @@ -0,0 +1,189 @@ +assertSame('2001:db8::1', (string) new IP('2001:DB8::0001')); + } + + public function testItKeepsIPv4MappedAddressesIntact(): void + { + $this->assertSame('::ffff:1.2.3.4', (string) new IP('::ffff:1.2.3.4')); + } + + public function testFlagsNarrowValidationToOneFamily(): void + { + $this->assertTrue((new IP('1.2.3.4'))->isValid(FILTER_FLAG_IPV4)); + $this->assertFalse((new IP('1.2.3.4'))->isValid(FILTER_FLAG_IPV6)); + $this->assertTrue((new IP('2001:db8::1'))->isValid(FILTER_FLAG_IPV6)); + } + + public function testBinaryAndHexRoundTrip(): void + { + $ip = new IP('1.2.3.4'); + + $this->assertSame('01020304', $ip->toHex()); + $this->assertSame(4, \strlen((string) $ip->toBinary())); + $this->assertSame('1.2.3.4', (string) new IP((string) $ip->toBinary())); + } + + public function testAnEmptyOrUnparseableValueIsNotValid(): void + { + $this->assertFalse((new IP(''))->isValid()); + $this->assertFalse((new IP('abcde'))->isValid()); + $this->assertFalse((new IP('999.999.999.999'))->isValid()); + } + + public function testAnyFourByteStringIsReadAsAPackedAddress(): void + { + // The constructor accepts the packed binary form, and it cannot tell that + // apart from a four character string. This is a sharp edge worth pinning + // down: 'nope' is not rejected, it becomes an address. + $this->assertSame('110.111.112.101', (string) new IP('nope')); + $this->assertTrue((new IP('nope'))->isValid()); + + // The same applies at 16 bytes, where it becomes an IPv6 address. + $this->assertTrue((new IP('not an ip at all'))->isValid()); + } + + public function testIp2RangeReadsARangeOfIPv6Addresses(): void + { + // The two ends of a range are only recognised as ends if they validate + // as addresses. Insisting on IPv4 there meant neither end of an IPv6 + // range was one, so this fell through to the "one side is a fragment" + // path and read the address itself as a list of octets to walk. + $range = IP::ip2range('2001:db8::1-2001:db8::ff'); + + $this->assertSame('2001:db8::1', (string) $range['low']); + $this->assertSame('2001:db8::ff', (string) $range['high']); + } + + public function testIp2RangeReadsAFullyWrittenIPv6Range(): void + { + // Same range as above with nothing elided, so the shortening on the way + // back out is the only difference between the two. + $range = IP::ip2range('2001:db8:0:0:0:0:0:1-2001:db8:0:0:0:0:0:ff'); + + $this->assertSame('2001:db8::1', (string) $range['low']); + $this->assertSame('2001:db8::ff', (string) $range['high']); + } + + public function testIp2RangeStillReadsARangeOfIPv4Addresses(): void + { + $range = IP::ip2range('1.2.3.4-1.2.3.9'); + + $this->assertSame('1.2.3.4', (string) $range['low']); + $this->assertSame('1.2.3.9', (string) $range['high']); + } + + #[DataProvider('validityProvider')] + public function testValidity(string $input, bool $expected): void + { + $this->assertSame($expected, (new IP($input))->isValid()); + } + + #[DataProvider('ip2RangeWildcardProvider')] + public function testIp2RangeFillsWildcardsWithTheLowestAndHighestValue( + string $input, + string $low, + string $high, + ): void { + $range = IP::ip2range($input); + + $this->assertSame($low, (string) $range['low']); + $this->assertSame($high, (string) $range['high']); + } + + #[DataProvider('cidrProvider')] + public function testMatchToCIDR(string $ip, string $cidr, bool $expected): void + { + $this->assertSame($expected, (new IP($ip))->matchToCIDR($cidr)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function ip2RangeWildcardProvider(): array + { + return [ + 'ipv4 last octet' => ['1.2.3.*', '1.2.3.0', '1.2.3.255'], + 'ipv6 last group' => ['2001:db8::*', '2001:db8::', '2001:db8::ffff'], + 'ipv6 all but the first two groups' => [ + '2001:db8:*:*:*:*:*:*', + '2001:db8::', + '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff', + ], + // Not a range at all: both ends are the address itself. + 'a single ipv6 address' => ['2001:db8::1', '2001:db8::1', '2001:db8::1'], + // 'unknown' is what the log holds when the address was not recorded. + // It is deliberately turned into an address that cannot occur. + 'unknown' => ['unknown', '255.255.255.255', '255.255.255.255'], + ]; + } + + /** + * @return array + */ + public static function cidrProvider(): array + { + return [ + 'ipv4 inside' => ['192.168.1.55', '192.168.1.0/24', true], + 'ipv4 outside' => ['192.168.2.55', '192.168.1.0/24', false], + 'ipv4 single host' => ['192.168.1.55', '192.168.1.55/32', true], + 'ipv6 inside' => ['2001:db8::5', '2001:db8::/32', true], + 'ipv6 outside' => ['2001:dba::5', '2001:db8::/32', false], + 'ipv6 inside a /48' => ['2001:db8:1::5', '2001:db8:1::/48', true], + 'ipv6 outside a /48' => ['2001:db8:2::5', '2001:db8:1::/48', false], + // Every prefix length here is a multiple of four, and that is not a + // coincidence. The IPv6 branch builds its mask with + // str_repeat('f', (int) $cidr_subnetmask / 4), where the cast binds + // to the subnet mask rather than to the division, so anything else + // hands str_repeat() a float and throws a TypeError before the + // switch below it can add the odd nibble. Those three cases have + // therefore never run. Asserting the TypeError here would only + // preserve it, so this says so instead. + // The families are never mixed, whichever way round they are given. + 'ipv6 address against an ipv4 network' => ['2001:db8::5', '192.168.1.0/24', false], + 'ipv4 address against an ipv6 network' => ['192.168.1.55', '2001:db8::/32', false], + ]; + } + + /** + * @return array + */ + public static function validityProvider(): array + { + return [ + 'ipv4' => ['192.168.0.1', true], + 'ipv4 broadcast' => ['255.255.255.255', true], + 'ipv6' => ['2001:db8::1', true], + 'ipv6 loopback' => ['::1', true], + 'octet out of range' => ['256.1.1.1', false], + 'too few octets' => ['1.2.3', false], + 'empty' => ['', false], + // Any 4 or 16 byte string is read as a packed address instead, so a + // rubbish value only fails validation at some other length. See + // testAnyFourByteStringIsReadAsAPackedAddress(). + 'words' => ['not an ip address at all', false], + ]; + } +} diff --git a/tests/Unit/PunycodeTest.php b/tests/Unit/PunycodeTest.php new file mode 100644 index 0000000000..f2a4437642 --- /dev/null +++ b/tests/Unit/PunycodeTest.php @@ -0,0 +1,47 @@ +assertSame('example.com', (new Punycode())->encode('example.com')); + } + + #[DataProvider('domainProvider')] + public function testEncodeAndDecodeAreInverses(string $unicode, string $ascii): void + { + $punycode = new Punycode(); + + $this->assertSame($ascii, $punycode->encode($unicode)); + $this->assertSame($unicode, $punycode->decode($ascii)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function domainProvider(): array + { + return [ + 'german umlaut' => ['münchen.de', 'xn--mnchen-3ya.de'], + 'multiple labels' => ['münchen.beispiel.de', 'xn--mnchen-3ya.beispiel.de'], + ]; + } +} diff --git a/tests/Unit/SapiTest.php b/tests/Unit/SapiTest.php new file mode 100644 index 0000000000..8e5d3875fe --- /dev/null +++ b/tests/Unit/SapiTest.php @@ -0,0 +1,77 @@ +assertSame('/a/c', Sapi::canonicalPath('/a/./b/../c', false, false)); + $this->assertSame('/a', Sapi::canonicalPath('/a/b/..', false, false)); + } + + public function testTheSuiteRunsOnTheCommandLine(): void + { + $this->assertTrue(Sapi::isCLI()); + } + + public function testNoMemoryLimitIsReportedAsMoreThanAnythingWillNeed(): void + { + // A memory_limit of -1 means unlimited. Reporting it as 0 made + // setMemoryLimit() decide the current limit was too small and impose + // one, so asking for 128M on an unlimited server capped it at 128M. + $this->assertSame(PHP_INT_MAX, Sapi::memoryReturnBytes('-1')); + } + + public function testAPlainByteCountKeepsItsLastDigit(): void + { + // The designator is optional, and Graphics\Image passes a computed byte + // count without one. Stripping the last character regardless turned this + // into a tenth of the memory that was actually asked for. + $this->assertSame(50000000, Sapi::memoryReturnBytes('50000000')); + } + + public function testSurroundingWhitespaceIsIgnored(): void + { + $this->assertSame(67108864, Sapi::memoryReturnBytes(' 64M ')); + } + + #[DataProvider('memorySizeProvider')] + public function testMemoryReturnBytes(string $val, int $expected): void + { + $this->assertSame($expected, Sapi::memoryReturnBytes($val)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function memorySizeProvider(): array + { + return [ + 'kilobytes' => ['512K', 524288], + 'megabytes' => ['256M', 268435456], + 'gigabytes' => ['1G', 1073741824], + 'lowercase suffix' => ['256m', 268435456], + 'zero megabytes' => ['0M', 0], + 'plain byte count' => ['128', 128], + 'zero' => ['0', 0], + 'empty' => ['', 0], + ]; + } +} diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php new file mode 100644 index 0000000000..a1b8d401c0 --- /dev/null +++ b/tests/Unit/SecurityTest.php @@ -0,0 +1,77 @@ +assertTrue(Security::hashVerifyPassword('correct horse battery staple', $hash)); + } + + public function testAHashDoesNotVerifyAgainstAnythingElse(): void + { + $hash = Security::hashPassword('correct horse battery staple', self::COST); + + $this->assertFalse(Security::hashVerifyPassword('Correct horse battery staple', $hash)); + $this->assertFalse(Security::hashVerifyPassword('', $hash)); + } + + public function testHashingIsSaltedSoTheSamePasswordHashesDifferently(): void + { + $this->assertNotSame( + Security::hashPassword('same', self::COST), + Security::hashPassword('same', self::COST), + ); + } + + public function testHashesAreBcrypt(): void + { + $this->assertStringStartsWith('$2y$', Security::hashPassword('x', self::COST)); + } + + public function testTheCostFactorIsHonoured(): void + { + $this->assertStringStartsWith('$2y$04$', Security::hashPassword('x', 4)); + $this->assertStringStartsWith('$2y$05$', Security::hashPassword('x', 5)); + } + + public function testGeneratedPasswordsAreDistinctAndNonTrivial(): void + { + $first = Security::generatePassword(); + + $this->assertSame(20, \strlen($first)); + $this->assertNotSame($first, Security::generatePassword()); + } + + public function testGeneratedValidationCodesAreDistinctAndNonTrivial(): void + { + $first = Security::generateValidationCode(); + + $this->assertSame(10, \strlen($first)); + $this->assertNotSame($first, Security::generateValidationCode()); + } +} diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php new file mode 100644 index 0000000000..f6ce115b9e --- /dev/null +++ b/tests/Unit/TimeIntervalTest.php @@ -0,0 +1,151 @@ +assertSame('P1Y2M3DT4H5M6S', (string) new TimeInterval('P1Y2M3DT4H5M6S')); + } + + public function testTimeOnlyDurationsKeepTheirTimeDesignator(): void + { + // The point here is the 'T': without it, the 'M' would read as months + // rather than minutes. + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); + } + + public function testAZeroUnitIsDroppedOnTheWayBackOut(): void + { + // This used to be the canonical form: the class populated days for a + // duration naming no years or months, and stringifying wrote the days + // out whether there were any or not. Both ends of that are gone, so a + // duration written the old way now comes back in the short form. + $this->assertSame('PT30M', (string) new TimeInterval('P0DT30M')); + } + + public function testItCanBeBuiltFromAPlainDateInterval(): void + { + $this->assertSame( + 'P1D', + (string) TimeInterval::createFromDateInterval(new \DateInterval('P1D')), + ); + } + + public function testToSecondsIsMeasuredFromAGivenMoment(): void + { + $this->assertSame(3600, (new TimeInterval('PT1H'))->toSeconds(new \DateTimeImmutable('@0'))); + } + + public function testToSecondsDependsOnTheMomentForCalendarUnits(): void + { + // A month is not a fixed number of seconds. January is longer than + // February, and asking from a different starting point proves the + // interval is resolved against a real calendar rather than an average. + $january = (new TimeInterval('P1M'))->toSeconds(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $february = (new TimeInterval('P1M'))->toSeconds(new \DateTimeImmutable('2026-02-01T00:00:00Z')); + + $this->assertSame(31 * 86400, $january); + $this->assertSame(28 * 86400, $february); + } + + public function testToParsableSpellsTheDurationOut(): void + { + // Note the singular 'year' against the plural everything else; the + // units are pluralised one at a time based on their own value. + $this->assertSame( + '1 year 2 months 3 days 4 hours 5 minutes 6 seconds', + (new TimeInterval('P1Y2M3DT4H5M6S'))->toParsable(), + ); + } + + public function testItCarriesItsOwnValuesRatherThanHoldingThem(): void + { + // The class used to keep a \DateInterval of its own and answer for it + // through property hooks, which left the object it actually is empty. + // Everything below reads the object itself, as \DateInterval's own + // documented interface and every caller passing one to date arithmetic + // does. + $interval = new TimeInterval('P1Y2M3DT4H5M6S'); + + $this->assertInstanceOf(\DateInterval::class, $interval); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + $this->assertSame(5, $interval->i); + $this->assertSame(6, $interval->s); + } + + public function testDateArithmeticMovesTheDateByTheWholeInterval(): void + { + // This is what an empty object costs: \DateTime reads the interval's + // own properties and cannot see a stored one, so adding an interval + // that said it was a month and change moved the date by nothing at all. + $start = new \DateTimeImmutable('2026-01-01 00:00:00', new \DateTimeZone('UTC')); + + $this->assertSame( + '2026-02-03T03:00:00+00:00', + $start->add(new TimeInterval('P1M2DT3H'))->format('c'), + ); + + $this->assertSame( + '2025-11-28T21:00:00+00:00', + $start->sub(new TimeInterval('P1M2DT3H'))->format('c'), + ); + } + + public function testItMovesTheDateByTheSameAmountAPlainDateIntervalDoes(): void + { + $start = new \DateTimeImmutable('2026-01-01 00:00:00', new \DateTimeZone('UTC')); + + $this->assertSame( + $start->add(new \DateInterval('P1M2DT3H'))->format('c'), + $start->add(new TimeInterval('P1M2DT3H'))->format('c'), + ); + } + + public function testFractionalSecondsSurviveConstruction(): void + { + // The whole reason this class exists: \DateInterval accepts only the + // integer subset of the ISO 8601 duration spec. + $this->assertSame('PT1.5S', (string) new TimeInterval('PT1.5S')); + $this->assertSame(1.5, (new TimeInterval('PT1.5S'))->toSeconds(new \DateTimeImmutable('@0'))); + } + + public function testFormatFallsBackToDaysWhenTheTotalIsUnknown(): void + { + // %a is the total number of days, which only an interval produced by + // diff() knows; \DateInterval writes '(unknown)' for any other. When + // there are no years or months in the way, the days field is that + // total, so it is written instead. + $this->assertSame('1', (new TimeInterval('P1DT2H'))->format('%a')); + + // With a year in it, the days field is not the total and nothing can be + // substituted, so the honest answer is still the one \DateInterval gives. + $this->assertSame('(unknown)', (new TimeInterval('P1Y'))->format('%a')); + } + + /* + * localize() is not covered here. It is the other half of what #9499 put + * right - the unit order stopped depending on how the caller wrote the + * argument, and asking for 'a' when the total number of days is unknown now + * falls back to years, months and days instead of producing nothing - but + * every branch of it goes through Lang::getTxt(), which loads a language + * file, which wants Theme::$current and therefore Db::$db. It belongs to an + * integration suite. toParsable() above covers the same walk over the units + * with the strings hard coded, so the ordering is not entirely unwatched. + */ +} diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php new file mode 100644 index 0000000000..db7888de94 --- /dev/null +++ b/tests/Unit/UrlTest.php @@ -0,0 +1,216 @@ +assertSame('a.example.com', $url->host); + $this->assertSame('/a/b', $url->path); + $this->assertSame('c=d', $url->query); + $this->assertSame('f', $url->fragment); + $this->assertSame(8080, $url->port); + } + + public function testMissingComponentsAreNotSet(): void + { + $url = new Url('https://example.com'); + + $this->assertFalse(isset($url->query)); + $this->assertFalse(isset($url->fragment)); + } + + public function testCastingBackToStringPreservesTheUrl(): void + { + $original = 'https://example.com/a/b?c=d#f'; + + $this->assertSame($original, (string) new Url($original)); + } + + public function testToAsciiPunycodesAnInternationalisedHost(): void + { + $this->assertSame( + 'https://xn--mnchen-3ya.de/', + (string) (new Url('https://münchen.de/'))->toAscii(), + ); + } + + public function testToAsciiPercentEncodesANonAsciiPath(): void + { + $this->assertSame( + 'https://xn--mnchen-3ya.de/stra%C3%9Fe', + (string) (new Url('https://münchen.de/straße'))->toAscii(), + ); + } + + public function testToUtf8ReversesPunycode(): void + { + $this->assertSame( + 'münchen.de', + (new Url('https://xn--mnchen-3ya.de/'))->toUtf8()->host, + ); + } + + public function testTheSchemeIsReportedExactlyAsItWasWritten(): void + { + // Schemes are case insensitive, but this does not normalise them, so a + // caller comparing against 'https' must lowercase first. + $this->assertSame('HTTPS', (new Url('HTTPS://example.com'))->scheme); + } + + public function testIsSchemeMatchesTheSchemeAsWritten(): void + { + $this->assertTrue((new Url('https://example.com'))->isScheme('https')); + $this->assertTrue((new Url('https://example.com'))->isScheme(['http', 'https'])); + $this->assertFalse((new Url('https://example.com'))->isScheme('ftp')); + } + + public function testIsSchemeIgnoresCaseOnBothSides(): void + { + // RFC 3986 section 3.1: scheme names are case insensitive. The scheme is + // not normalised on parsing, so the comparison has to fold it. + $this->assertTrue((new Url('HTTPS://example.com'))->isScheme('https')); + $this->assertTrue((new Url('https://example.com'))->isScheme('HTTPS')); + $this->assertTrue((new Url('HtTp://example.com'))->isScheme(['http', 'https'])); + } + + public function testAnUppercaseSchemeIsStillAWebsite(): void + { + $this->assertTrue((new Url('HTTP://example.com'))->isWebsite()); + $this->assertTrue((new Url('HTTPS://example.com'))->isWebsite()); + $this->assertFalse((new Url('ftp://example.com'))->isWebsite()); + } + + public function testAnUppercaseDataUriIsRecognised(): void + { + // User's avatar handling asks isScheme('data') to decide whether the + // value is an inline image or a remote address. + $this->assertTrue((new Url('DATA:image/png;base64,AAAA'))->isScheme('data')); + } + + public function testAnIPv6HostIsWrittenInBrackets(): void + { + // The brackets are part of the authority, not part of the address, so + // the host comes back with them still on it. Anything wanting to treat + // the host as an address has to take them off first, which is what + // proxied() was not doing. + $this->assertSame('[2001:db8::1]', (new Url('http://[2001:db8::1]/pic.png'))->host); + } + + #[DataProvider('validityProvider')] + public function testValidity(string $input, bool $expected): void + { + $this->assertSame($expected, (new Url($input))->isValid()); + } + + #[DataProvider('proxiedProvider')] + public function testProxiedLeavesUnroutableHostsAlone(string $url, bool $expected): void + { + $this->withProxySettings(function () use ($url, $expected): void { + $proxied = (string) Url::create($url)->proxied(); + + if ($expected) { + $this->assertStringStartsWith( + 'https://forum.test-site.com/forum/proxy.php?request=', + $proxied, + ); + } else { + $this->assertSame($url, $proxied); + } + }); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function validityProvider(): array + { + return [ + 'https' => ['https://example.com', true], + 'http with path' => ['http://example.com/a/b', true], + 'bare word' => ['notaurl', false], + 'empty' => ['', false], + ]; + } + + /** + * The point of the proxy is to serve an http image over https without + * telling the person's browser to go and fetch it. Sending it at a host + * only the server can reach turns it into a request the server makes on + * behalf of whoever pasted the address, which is the shape of an SSRF, so + * the private and reserved ranges are excluded. + * + * @return array + */ + public static function proxiedProvider(): array + { + return [ + // The IPv6 cases are the ones that regressed: filter_var() does not + // accept an address in brackets, so every one of these read as a + // name rather than an address and went to the proxy. + 'ipv6 private' => ['http://[fd00::1]/pic.png', false], + 'ipv6 loopback' => ['http://[::1]/pic.png', false], + 'ipv6 documentation' => ['http://[2001:db8::1]/pic.png', false], + 'ipv6 global' => ['http://[2606:4700:4700::1111]/pic.png', true], + + // The IPv4 side is the control: it was already right and stays right. + 'ipv4 private' => ['http://10.0.0.1/pic.png', false], + 'ipv4 loopback' => ['http://127.0.0.1/pic.png', false], + 'ipv4 global' => ['http://93.184.216.34/pic.png', true], + ]; + } + + /****************** + * Internal methods + ******************/ + + /** + * Runs $test with the image proxy switched on, then puts the settings back. + * + * These are typed statics with no default, so they start out uninitialised + * and there is no way to put them back into that state. Restoring the + * disabled state is the nearest thing: it is what Config::load() writes + * when the proxy is off, and every check on the way in asks empty(), which + * reads an uninitialised property and a false one alike. + * + * @param callable $test The assertions to run. + */ + protected function withProxySettings(callable $test): void + { + $enabled = Config::$image_proxy_enabled ?? false; + $secret = Config::$image_proxy_secret ?? ''; + $boardurl = Config::$boardurl ?? ''; + + Config::$image_proxy_enabled = true; + Config::$image_proxy_secret = 'smfisawesome'; + Config::$boardurl = 'https://forum.test-site.com/forum'; + + try { + $test(); + } finally { + Config::$image_proxy_enabled = $enabled; + Config::$image_proxy_secret = $secret; + Config::$boardurl = $boardurl; + } + } +} diff --git a/tests/Unit/UtilsTest.php b/tests/Unit/UtilsTest.php new file mode 100644 index 0000000000..ffc139b707 --- /dev/null +++ b/tests/Unit/UtilsTest.php @@ -0,0 +1,184 @@ +assertSame('(?>ab(?>c|d))', Utils::buildRegex(['abc', 'abd'])); + } + + public function testBuildRegexMatchesEveryStringItWasBuiltFrom(): void + { + $strings = ['abc', 'abd', 'xyz', 'a.b', 'a+b', 'a(b)']; + $regex = Utils::buildRegex($strings); + + foreach ($strings as $string) { + $this->assertMatchesRegularExpression('~^' . $regex . '$~', $string); + } + } + + public function testBuildRegexQuotesTrailingSpecialCharacters(): void + { + // A trailing character that is special in a regex must stay quoted, or the + // resulting pattern matches things it should not. + $regex = Utils::buildRegex(['ab.', 'ab']); + + $this->assertMatchesRegularExpression('~^' . $regex . '$~', 'ab.'); + $this->assertDoesNotMatchRegularExpression('~^' . $regex . '$~', 'abx'); + } + + public function testBuildRegexHandlesASingleString(): void + { + $this->assertMatchesRegularExpression('~^' . Utils::buildRegex(['solo']) . '$~', 'solo'); + } + + public function testEntityAwareLengthCountsAnEntityAsOneCharacter(): void + { + $this->assertSame(3, Utils::entityStrlen('a&b')); + $this->assertSame(4, Utils::entityStrlen('déjà')); + } + + public function testEntityAwareSubstrDoesNotSplitAnEntity(): void + { + $this->assertSame('a&', Utils::entitySubstr('a&bc', 0, 2)); + } + + public function testEntityAwareStrposCountsEntitiesAsOne(): void + { + $this->assertSame(2, Utils::entityStrpos('a&bc', 'b')); + } + + public function testEntityAwareSplitKeepsEntitiesWhole(): void + { + $this->assertSame(['a', '&', 'b'], Utils::entityStrSplit('a&b')); + } + + public function testHtmlTrimRemovesEntityWhitespaceAtBothEnds(): void + { + $this->assertSame('a', Utils::htmlTrim('   a   ')); + $this->assertSame('a', Utils::htmlTrimLeft('  a')); + $this->assertSame('a', Utils::htmlTrimRight('a  ')); + } + + public function testTruncateRefusesToCutAnEntityInHalf(): void + { + $this->assertSame('abcde', Utils::truncate('abcdefghij', 5)); + + // '&' would not fit in the remaining budget, so it is dropped whole + // rather than emitted as a broken fragment. + $this->assertSame('a', Utils::truncate('a&bcdef', 5)); + } + + public function testShortenAppendsAnEllipsisOnlyWhenItShortens(): void + { + $this->assertSame('abcde...', Utils::shorten('abcdefghij', 5)); + $this->assertSame('abc', Utils::shorten('abc', 5)); + } + + public function testNormalizeComposesAndDecomposes(): void + { + $this->assertSame("\u{00E1}", Utils::normalize("a\u{0301}", 'c')); + $this->assertSame(2, mb_strlen(Utils::normalize("\u{00E1}", 'd'))); + } + + public function testConvertCaseHandlesCharactersWithNoSimpleMapping(): void + { + // Uppercasing the sharp s expands it to two characters, which a naive + // strtoupper() on bytes cannot do. + $this->assertSame('STRASSE', Utils::convertCase('Straße', 'upper')); + $this->assertSame('Hello World', Utils::convertCase('hello world', 'title')); + } + + public function testConvertCaseTitlecasesDigraphsToTheirTitleForm(): void + { + // U+01F3 dz titlecases to U+01F2 Dz, which is neither upper nor lower. + $this->assertSame("\u{01F2}", Utils::convertCase("\u{01F3}", 'title')); + } + + public function testConvertCaseFoldsForCaseInsensitiveComparison(): void + { + $this->assertSame( + Utils::convertCase('ÄÖÜ', 'fold'), + Utils::convertCase('äöü', 'fold'), + ); + } + + public function testSanitizeCharsReplacesDirectionalOverridesAtLevelOne(): void + { + // A right-to-left override can be used to disguise a file name or link. + $this->assertSame("a\u{202E}b", Utils::sanitizeChars("a\u{202E}b", 0)); + $this->assertSame("a\u{FFFD}b", Utils::sanitizeChars("a\u{202E}b", 1)); + } + + public function testNormalizeSpacesCollapsesExoticWhitespace(): void + { + $this->assertSame('a b', Utils::normalizeSpaces("a\u{00A0}b", true, true)); + } + + public function testSanitizeEntitiesReplacesEntitiesForControlCharacters(): void + { + $this->assertSame('�', Utils::sanitizeEntities('')); + $this->assertSame('A', Utils::sanitizeEntities('A')); + } + + public function testHtmlspecialcharsLeavesSingleQuotesAloneByDefault(): void + { + $this->assertSame('a"b\'c<>&', Utils::htmlspecialchars('a"b\'c<>&')); + $this->assertSame('a"b'c', Utils::htmlspecialchars('a"b\'c', ENT_QUOTES)); + } + + public function testHtmlspecialcharsDecodeRoundTrips(): void + { + $original = 'a"b&d'; + + $this->assertSame( + $original, + Utils::htmlspecialcharsDecode(Utils::htmlspecialchars($original, ENT_QUOTES)), + ); + } + + public function testJsonRoundTrips(): void + { + $this->assertSame('{"a":1}', Utils::jsonEncode(['a' => 1])); + $this->assertSame(['a' => 1], Utils::jsonDecode('{"a":1}', true)); + } + + #[DataProvider('entityLengthProvider')] + public function testEntityStrlenAcrossInputs(string $input, int $expected): void + { + $this->assertSame($expected, Utils::entityStrlen($input)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function entityLengthProvider(): array + { + return [ + 'empty' => ['', 0], + 'ascii' => ['abc', 3], + 'named entity' => ['&', 1], + 'numeric entity' => ['©', 1], + 'multibyte' => ["\u{00E9}\u{00E8}", 2], + 'mixed' => ['a&é', 3], + ]; + } +} diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php new file mode 100644 index 0000000000..b09b7e65c7 --- /dev/null +++ b/tests/Unit/UuidTest.php @@ -0,0 +1,107 @@ +assertMatchesRegularExpression( + '~^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$~', + (string) Uuid::create(4), + ); + } + + public function testGeneratedUuidsAreDistinct(): void + { + $this->assertNotSame((string) Uuid::create(4), (string) Uuid::create(4)); + } + + public function testTheVariantIsAlwaysTheRfcOne(): void + { + $this->assertSame(1, Uuid::create(4)->getVariant()); + $this->assertSame(1, Uuid::create(7)->getVariant()); + } + + public function testTheNilUuidRoundTripsAndReportsVersionZero(): void + { + $uuid = Uuid::createFromString(self::NIL); + + $this->assertSame(self::NIL, (string) $uuid); + $this->assertSame(0, $uuid->getVersion()); + } + + public function testTheBinaryFormIsSixteenBytes(): void + { + $this->assertSame(16, \strlen(Uuid::create(4)->getBinary())); + } + + public function testTheShortFormIsTwentyTwoCharacters(): void + { + $this->assertSame(22, \strlen(Uuid::create(4)->getShortForm())); + } + + public function testCompressAndExpandRoundTrip(): void + { + $uuid = (string) Uuid::create(4); + + $this->assertSame($uuid, Uuid::expand(Uuid::compress($uuid))); + } + + public function testStrictParsingRejectsRubbish(): void + { + $this->expectException(\ValueError::class); + + Uuid::createFromString('not-a-uuid', true); + } + + public function testVersionSevenUuidsSortByCreationOrder(): void + { + // Version 7 puts a millisecond timestamp in the high bits, so the string + // form is monotonic. That is the whole point of using it for keys. + $first = (string) Uuid::create(7); + usleep(2000); + $second = (string) Uuid::create(7); + + $this->assertLessThan(0, strcmp($first, $second)); + } + + #[DataProvider('versionProvider')] + public function testCreateProducesTheRequestedVersion(int $version): void + { + $this->assertSame($version, Uuid::create($version)->getVersion()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function versionProvider(): array + { + return [ + 'v4 random' => [4], + 'v7 time ordered' => [7], + ]; + } +} diff --git a/tests/Unit/index.php b/tests/Unit/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/tests/Unit/index.php @@ -0,0 +1,8 @@ +setPsr4('SMF\\', TESTS_BOARDDIR . '/Sources'); +$loader->setPsr4('SMF\\Themes\\', TESTS_BOARDDIR . '/Themes'); + +/* + * Paths and the default language, which the Unicode and entity helpers need in + * order to locate their data files. These are the only pieces of Config the suite + * sets: no modSettings, no database credentials, nothing read from Settings.php. + * A test that needs more than this is an integration test. + */ +SMF\Config::$boarddir = (string) realpath(TESTS_BOARDDIR); +SMF\Config::$sourcedir = SMF\Config::$boarddir . '/Sources'; +SMF\Config::$packagesdir = SMF\Config::$boarddir . '/Packages'; +SMF\Config::$languagesdir = SMF\Config::$boarddir . '/Languages'; +SMF\Config::$cachedir = SMF\Config::$boarddir . '/cache'; +SMF\Config::$language = 'en_US'; diff --git a/tests/index.php b/tests/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/tests/index.php @@ -0,0 +1,8 @@ +