diff --git a/AGENTS.md b/AGENTS.md index 7b027d901..3afd64e8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -434,7 +434,7 @@ PHPUnit loads test files directly (not via autoloading), so the namespace doesn' ### Temp directories for file I/O -Tests that write files to disk must never write to the committed `tests/` directory. For tests needing a full app skeleton, `Testbench\TestCase` handles this automatically (see testbench entry in the paths table above). For unit/lightweight tests that just need a scratch directory, use `ParallelTesting::tempDir('TestName')` — store it as a property, delete any leftover copy and create it fresh in `setUp`, then delete it again via `Filesystem::deleteDirectory()` in `tearDown`. Use `sys_get_temp_dir()` directly only when the system temporary path itself is the behavior under test. See `FoundationViteTest` or `OptionTest` for the pattern. +Tests that write files to disk must never write to the committed `tests/` directory. For tests needing a full app skeleton, `Testbench\TestCase` handles this automatically (see testbench entry in the paths table above). For any test that only needs an isolated scratch directory, use `ParallelTesting::tempDir('TestName')` — store it as a property, delete any leftover copy and create it fresh in `setUp`, then delete it again via `Filesystem::deleteDirectory()` in `tearDown`. Use `sys_get_temp_dir()` directly only when the system temporary path itself is the behavior under test. See `FoundationViteTest` or `OptionTest` for the pattern. The Testbench skeleton clone is shared for the whole worker, so tests that write under `BASE_PATH` must restore or delete the exact files they touch in `tearDown()`. For `.env` files, prefer `useEnvironmentPath()` with an isolated `ParallelTesting::tempDir()` directory. @@ -700,6 +700,8 @@ The `tests/` directory is excluded from phpstan. Do not run phpstan on tests. Full PHPStan runs through `composer fix` at checkpoints. During implementation, use targeted PHPStan only when investigating or validating a specific type issue. +`phpstan.types.neon.dist` validates only the committed `types/` fixtures. Never pass source or test paths to it. + **When fixing phpstan errors:** 1. **Investigate before coding.** For each error: read the code, check the Laravel equivalent's types (native and docblock), trace through callers and dependents. Report findings with the single, most correct fix. diff --git a/composer.json b/composer.json index 97697097c..f11547dfe 100644 --- a/composer.json +++ b/composer.json @@ -138,9 +138,7 @@ "ext-dom": "*", "ext-fileinfo": "*", "ext-filter": "*", - "ext-hash": "*", "ext-intl": "*", - "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", "ext-pcntl": "*", diff --git a/docs/plans/2026-07-01-0915-fortify-passkeys-port.md b/docs/plans/2026-07-01-0915-fortify-passkeys-port.md index 92f7d08e7..2ab828b20 100644 --- a/docs/plans/2026-07-01-0915-fortify-passkeys-port.md +++ b/docs/plans/2026-07-01-0915-fortify-passkeys-port.md @@ -371,7 +371,6 @@ The `hypervel/queue` dependency is intentional for both packages if their ported "keywords": ["php", "hypervel", "passkeys", "webauthn", "passwordless", "swoole"], "require": { "php": "^8.4", - "ext-json": "*", "hypervel/auth": "^0.4", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", @@ -422,7 +421,6 @@ The `hypervel/queue` dependency is intentional for both packages if their ported "keywords": ["php", "hypervel", "fortify", "auth", "swoole"], "require": { "php": "^8.4", - "ext-json": "*", "bacon/bacon-qr-code": "^3.1", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 716fe0c80..fd107a321 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `sentry`; the complete correctness, coroutine-ownership, parity, and performance audit is recorded under `Complete Sentry correctness, coroutine ownership, and performance`; detail plan `2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md`. -- **Ledger entries required for the active work:** `Complete Sentry correctness, coroutine ownership, and performance`; `Make coroutine creation and copied context failure-safe`; `Harden Core lifecycle callbacks and stdout logging`; `Isolate object-pool maintenance and remove false dependencies`; `Harden filesystem I/O, streaming, and response teardown`; `Complete Cache parity, cleanup, permanence, and tagged ownership`; `Complete Notifications correctness, Slack parity, and reentrant failure ownership`; `Complete Queue pooling, payload durability, and current Laravel parity`; `Correct AOP proxy generation and publication`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; and `Harden Server startup, reload, and process lifecycles`. -- **Pending revalidation carried into the active work:** Telescope must retain captured fork values under `coroutine-08`; Sentry and every other named consumer are revalidated in this work unit. +- **Active package or work unit:** `testing`; the complete correctness, parallel-ownership, parity, and metadata audit is recorded under `Complete Testing correctness, parallel ownership, and current parity`; detail plan `2026-08-08-2225-testing-correctness-parallel-ownership-and-current-parity.md`. +- **Ledger entries required for the active work:** `Complete Testing correctness, parallel ownership, and current parity`; `Complete Mail correctness, current parity, and package boundaries`; `Complete Macroable callable and test-state handling`; `Consolidate reflection metadata and correct callable inference`; `Preserve configuration identity across worker reloads`; `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; `Unify HTTP response emission and harden native server boundaries`; `Complete Database persistence lifecycles and current Laravel parity`; `Complete Nested Set invariants, performance, and modern APIs`; `Harden Eloquent identity and partial-projection safety`; and `Complete View correctness, lifecycle, and current parity`. +- **Pending revalidation carried into the active work:** Telescope must retain captured fork values under `coroutine-08`; Testing introduces no additional pending revalidation. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1008,13 +1008,13 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-01` | `queue` | `contracts` and `queue` (revalidation complete) | `Harden framework contracts and request-scoped state`; shared finding `queue-01` | | `contracts-05` | `contracts` | `http`, `foundation`, `console`, `database`, and `routing` (revalidation complete) | `Harden framework contracts and request-scoped state`; finding `contracts-05` | | `testbench-01` | `testbench` | `foundation` (revalidation complete); later full `testbench` audit | `Restore Conditionable proxy truthiness`; shared finding `testbench-01` | -| `http-01` | `http` | `macroable` and `http` (revalidation complete), `testing`; later full `testing` audit | `Complete Macroable callable and test-state handling`; shared finding `http-01` | +| `http-01` | `http` | `macroable`, `http`, and `testing` (revalidation complete) | `Complete Macroable callable and test-state handling`; shared finding `http-01` | | `console-01` | `console` | `contracts` and `console` (revalidation complete) | `Preserve typed console contracts during Composer scripts`; shared finding `console-01` | | `reflection-01` | `reflection` | `events` and `foundation` (revalidation complete) | `Consolidate reflection metadata and correct callable inference`; finding `reflection-01` | | `reflection-02` | `reflection` | `foundation`, `console`, `routing`, and `view` (revalidation complete) | `Consolidate reflection metadata and correct callable inference`; finding `reflection-02` | -| `reflection-04` | `reflection` | `di` and `queue` (revalidation complete), `support`, `testing`; later full consumer audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-04` | +| `reflection-04` | `reflection` | `di`, `queue`, `support`, and `testing` (revalidation complete) | `Consolidate reflection metadata and correct callable inference`; finding `reflection-04` | | `config-01` | `config` | `foundation` (revalidation complete) | `Preserve configuration identity across worker reloads`; finding `config-01` | -| `config-02` | `foundation` | `reverb` (revalidation complete), `testing`; later full `testing` audit | `Preserve configuration identity across worker reloads`; finding `config-02` | +| `config-02` | `foundation` | `reverb` and `testing` (revalidation complete) | `Preserve configuration identity across worker reloads`; finding `config-02` | | `container-05` | `container` | `context` (revalidation complete) | `Coordinate shared container construction and complete current contextual resolution`; finding `container-05` | | `container-06` | `container` | `context` (revalidation complete) | `Coordinate shared container construction and complete current contextual resolution`; finding `container-06` | | `container-08` | `container` | `auth`, `cache`, `log`, `support`; `routing` (revalidation complete); later full consumer audits | `Coordinate shared container construction and complete current contextual resolution`; finding `container-08` | @@ -1064,13 +1064,13 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `server-11` | `foundation`, `server` | `server-process` and `reverb` (revalidation complete) | `Complete Signal handler reliability, public APIs, and deployment guidance`; finding `server-11` | | `bus-03` | `bus`, `contracts`, `foundation` | `foundation` and `queue` (revalidation complete) | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-03` | | `bus-10` | `bus`, `queue` | `queue` (revalidation complete) | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-10` | -| `bus-17` | `bus`, `foundation`, `queue`, `testing` | `log`, `foundation`, and `queue` (revalidation complete); later full `testing` audit | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-17` | +| `bus-17` | `bus`, `foundation`, `queue`, `testing` | `log`, `foundation`, `queue`, and `testing` (revalidation complete) | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-17` | | `bus-18` | `foundation`, `queue` | `foundation` and `queue` (revalidation complete) | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-18` | | `core-01` | `core`, `foundation` | `foundation` (revalidation complete) | `Harden Core lifecycle callbacks and stdout logging`; finding `core-01` | | `core-05` | `core`, `foundation` | `foundation` (revalidation complete) | `Harden Core lifecycle callbacks and stdout logging`; finding `core-05` | | `core-06` | `core`, `server` | `server` (revalidation complete) | `Harden Core lifecycle callbacks and stdout logging`; finding `core-06` | -| `http-server-03` | `http-server`, `filesystem`, `http`, `foundation` | `context`, `contracts`, `engine`, `http`, and `testing` (revalidation complete); later full `testing` audit | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-03` | -| `http-server-05` | `testing` | `http-server` (revalidation complete); later full `testing` audit | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-05` | +| `http-server-03` | `http-server`, `filesystem`, `http`, `foundation` | `context`, `contracts`, `engine`, `http`, and `testing` (revalidation complete) | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-03` | +| `http-server-05` | `testing` | `http-server` and `testing` (revalidation complete) | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-05` | | `http-server-06` | `http-server` | `reverb`, `websocket-server`, and `grpc` (revalidation complete) | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-06` | | `http-server-07` | `http-server` | `grpc` (revalidation complete) | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-07` | | `http-server-08` | `http-server`, `foundation` | `grpc` (revalidation complete) | `Unify HTTP response emission and harden native server boundaries`; finding `http-server-08` | @@ -1088,7 +1088,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `watcher-10` | `support` | `watcher`, `foundation`, and `horizon` (revalidation complete) | `Make Watcher drivers and managed processes lifecycle-safe`; finding `watcher-10` | | `database-05` | `core`, `database` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-05`; sibling finding `redis-03` | | `database-06` | `core`, `server`, `database` | `server` and `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-06`; sibling finding `redis-05` | -| `database-08` | `database` | `foundation`, `testing`, and `testbench` (revalidation complete); later full `testing` and `testbench` audits | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-08` | +| `database-08` | `database` | `foundation`, `testing`, and `testbench` (revalidation complete); later full `testbench` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-08` | | `database-10` | `database` | `scout` and `nested-set` (revalidation complete); later full consumer audits | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-10` | | `database-14` | `database` | `queue` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-14` | | `redis-03` | `redis` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-03` | @@ -1134,8 +1134,8 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `websocket-server-13` | `websocket-server` | `websocket-server` (revalidation complete); Reverb path confirmed unaffected | `Complete Reverb connection, shared-state, and current Laravel parity lifecycles`; finding `websocket-server-13` | | `testbench-02` | `testbench` | `testbench` (targeted correction complete); later full `testbench` audit | `Complete Reverb connection, shared-state, and current Laravel parity lifecycles`; finding `testbench-02` | | `support-27` | `support` | `support` and `websocket-server` (revalidation complete); Reverb path confirmed unaffected | `Complete Reverb connection, shared-state, and current Laravel parity lifecycles`; finding `support-27` | -| `nested-set-13` | `nested-set` | `testing` (revalidation complete); later full `testing` audit | `Complete Nested Set invariants, performance, and modern APIs`; finding `nested-set-13` | -| `database-15` | `database` | `database` and `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `database-15` | +| `nested-set-13` | `nested-set` | `testing` (revalidation complete) | `Complete Nested Set invariants, performance, and modern APIs`; finding `nested-set-13` | +| `database-15` | `database` | `database` (targeted correction complete), `testing` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `database-15` | | `database-16` | `database` | `database` (targeted correction complete) | `Harden Eloquent identity and partial-projection safety`; finding `database-16` | | `database-17` | `database` | `database` (targeted correction complete) | `Harden Eloquent identity and partial-projection safety`; finding `database-17` | | `database-18` | `database` | `database` and `queue` (targeted correction complete) | `Harden Eloquent identity and partial-projection safety`; finding `database-18` | @@ -1212,14 +1212,14 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `http-04` | `http` | `http` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `http-04` | | `http-05` | `http` | `http` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `http-05` | | `http-06` | `http` | `http` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `http-06` | -| `testing-01` | `testing` | `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `testing-01` | -| `testing-02` | `testing` | `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `testing-02` | +| `testing-01` | `testing` | `testing` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `testing-01` | +| `testing-02` | `testing` | `testing` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `testing-02` | | `routing-01` | `contracts`, `foundation`, `routing`, `support` | `contracts`, `foundation`, `routing`, `support`, and `http` (revalidation complete) | `Complete HTTP correctness, JSON:API, and current Laravel parity`; finding `routing-01` | | `testbench-03` | `testbench` | `http` (revalidation complete); later full `testbench` audit | `Complete HTTP correctness, JSON:API, and current Laravel parity`; finding `testbench-03` | | `database-21` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-21` | | `database-22` | `database` | `database` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-22` | | `database-23` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-23` | -| `mail-17` | `mail` | `mail` and `support` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `mail-17` | +| `mail-17` | `mail` | `mail`, `support`, and `testing` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; findings `mail-17` and `testing-06` | | `support-28` | `support` | `support` and `mail` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `support-28` | | `support-29` | `support` | `support`, `mail`, and `validation` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `support-29` | | `contracts-10` | `contracts` | `contracts` and `mail` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `contracts-10` | @@ -1233,7 +1233,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `validation-18` | `validation` | `validation` and `support` (revalidation complete) | `Complete Validation correctness, parity, and compiled lifecycles`; finding `validation-18` | | `view-09` | `foundation` | `foundation` and `view` (revalidation complete) | `Complete View correctness, lifecycle, and current parity`; finding `view-09` | | `view-24` | `foundation` | `foundation` and `view` (revalidation complete) | `Complete View correctness, lifecycle, and current parity`; finding `view-24` | -| `view-37` | `view` | `view` (revalidation complete), `foundation`, `testbench`, and `testing` (targeted corrections complete); later full `testbench` and `testing` audits | `Complete View correctness, lifecycle, and current parity`; finding `view-37` | +| `view-37` | `view` | `view` and `testing` (revalidation complete), `foundation` and `testbench` (targeted correction complete); later full `testbench` audit | `Complete View correctness, lifecycle, and current parity`; finding `view-37` | | `view-38` | `view` | `view` (revalidation complete), `boost` (targeted correction complete); later full `boost` audit | `Complete View correctness, lifecycle, and current parity`; finding `view-38` | | `translation-10` | `translation` | `translation` and `view` (sibling revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `translation-10` | | `translation-02` | `contracts`, `translation` | `auth`, `foundation`, `support`, `translation`, and `validation` (revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `translation-02` | @@ -1259,6 +1259,20 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-43` | `queue` | `sentry` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-43` | | `queue-44` | `queue` | `sentry` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-44` | | `queue-45` | `queue` | `sentry` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-45` | +| `testing-03` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-03` | +| `testing-04` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-04` | +| `testing-05` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-05` | +| `testing-06` | `testing` | `testing` and `mail` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-06` | +| `testing-07` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-07` | +| `testing-08` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-08` | +| `testing-09` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-09` | +| `testing-10` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-10` | +| `testing-11` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-11` | +| `testing-12` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-12` | +| `testing-13` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-13` | +| `testing-14` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-14` | +| `testing-15` | `testing` | `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-15` | +| `testing-16` | `testing` | `auth`, `broadcasting`, `encryption`, `filesystem`, `fortify`, `horizon`, `passkeys`, `routing`, `sanctum`, `socialite`, `telescope`, and `testing` (revalidation complete) | `Complete Testing correctness, parallel ownership, and current parity`; finding `testing-16` | ## Package checklist @@ -1365,7 +1379,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen ### Tooling and developer surfaces -- [ ] `testing` +- [x] `testing` - [ ] `testbench` - [x] `prompts` - [ ] `tinker` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 5c8224c87..bf1a669c1 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -175,7 +175,8 @@ Append package entries in checklist order. Keep each entry compact but complete | `http-01` | Defect | Minor | High | The new Request reset clears macros but leaves four inherited mutable Symfony configuration surfaces alive between framework tests | Reset only the inherited MIME-format, method-override, allowed-method, and request-factory state that Hypervel still uses | - **Important rejected concerns:** Do not make macros coroutine-local, lock immutable runtime reads, clone or cache callables, or add a manager/provider/reset registry. Current Laravel 13.x still fails closures produced from internal functions and first-class methods; Hypervel intentionally supports its existing callable contract, and the local source comment explains the PHP invariant without narrating upstream history. Focused PHPUnit coverage disproved the original assumption that unsupported bindings are silent outside a booted application. The checked native `bindTo()` boundaries therefore suppress their expected warnings and immediately inspect the nullable result; per-call reflection costs more, registration-time classification adds disproportionate parallel state, and warning-tolerant tests would hide real standalone behavior. -- **Cross-package implications:** Cookie, JWT, Log, and Notifications have revalidated their direct Macroable dependencies. The later `database`, `support`, and `testing` audits must retain the completed static reset boundaries and centralized subscriber ownership. The full HTTP audit retained the completed inherited-state reset under shared finding `http-01`; Testing still owns its later full-audit revalidation. +- **Cross-package implications:** Cookie, JWT, Log, and Notifications have revalidated their direct Macroable dependencies. The later `database` and `support` audits must retain the completed static reset boundaries and centralized subscriber ownership. The full HTTP audit retained the completed inherited-state reset under shared finding `http-01`. +- **Later Testing revalidation:** `http-01` retains Request's inherited-state reset and the centralized subscriber ownership of framework-static cleanup; no Testing-local registry or duplicate reset was added. - **Completeness method:** Consumer/reset counts come from a whole-source trait-composition sweep, and documentation contradiction counts come from a whole-docs pattern sweep rather than an expected-file enumeration. - **Approved implementation boundary:** Keep the two magic dispatch paths explicit because their legal binding sequences differ. Add no helper or dynamic abstraction. Existing non-Closure callable dispatch is unchanged; ordinary closure macros still perform one binding operation. Suppressing the checked native warning adds a measured approximately 16 nanoseconds to closure-macro invocation; the owner approved that negligible opt-in cost after reviewing the more expensive or more complex alternatives. The additional binding attempt is confined to previously failing closure shapes. Static cleanup runs only between tests. Owner approval covers the callable behavior, eight additive public testing hooks, cross-package manifest corrections, and test-signature improvement. - **Implementation:** Macro dispatch now applies the legal instance/class and static/class binding sequence, suppresses only the native warnings whose nullable results are checked immediately, and retains already-valid first-class callables when PHP forbids rebinding. Three existing static resets now include macros; eight framework classes gained the standard static reset hook and central subscriber registration. Request's reset also restores the four mutable inherited Symfony configuration surfaces that remain live in Hypervel. Cookie, JWT, log, and notifications declare their direct Macroable dependency. Collections, HTTP-client, and response documentation now matches centralized framework cleanup. Macroable's own fixtures release their registries, and the earlier Testbench manifest regression now forces its restoration path with deterministic valid-PHP probe content instead of assuming a child rebuild must differ from the baseline. @@ -215,7 +216,8 @@ Append package entries in checklist order. Keep each entry compact but complete | `collections-13` | Defect | Minor | High | `times()` without a callback always yields integers but reports an unresolved generic and infers `mixed` through the contract and both implementations | Use one variance-safe conditional return across all three declarations and regress omitted-callback and callback-result inference for eager and lazy collections | - **Important rejected concerns:** Do not add coroutine context, locks, scoped bindings, cloning, or cleanup around collection instances or lazy iterators; the package has no hidden request state or owned asynchronous lifecycle. Do not blanket-copy Laravel annotations or style-only rewrites, remove deliberate Hypervel behavior such as depth-aware dot flattening and subclass factory hooks, or claim that one mutable lazy iterator is safe for concurrent consumption. Keep array-native paths instead of materializing ordinary arrays merely to support Traversables. -- **Cross-package implications:** None. The split manifest directly owns its corrected polyfill and optional dumper metadata. Later `support` and `testing` audits must retain the already-complete centralized macro/proxy resets but receive no new contract from this work. +- **Cross-package implications:** None. The split manifest directly owns its corrected polyfill and optional dumper metadata. The later `support` audit must retain the already-complete centralized macro/proxy resets but receives no new contract from this work. +- **Later Testing revalidation:** Testing retains the centralized macro/proxy resets without changing Collections source or adding a second cleanup owner. - **Approved implementation boundary:** Owner approved the additive Laravel APIs, the deprecated-alias removal, truthful `Arr::push()` narrowing, selective public type corrections, test improvements, and intentional-omission README. Owner separately approved extending `Enumerable::random()` with the optional key-preservation argument shared by both implementations, correcting its conditional key type instead of retaining Laravel's inaccurate interface annotation. Owner also approved correcting callback-based `sum()` inference through the shared contract: the result includes the integer identity, floating-point accumulation, and extension arithmetic objects instead of pretending the total is one callback result. Owner approved the measured correctness costs: approximately 3–4 nanoseconds per `sum()` item to forward keys and approximately 4 nanoseconds per ordinary `Arr::last()` call to preserve its iterable contract. Common `keyBy()` scalar work becomes slightly cheaper, array-native `every()`/`some()` paths remain, non-array materialization is confined to `last()` calls that actually receive a Traversable, and the timestamp path becomes faster. Documentation will additionally cover key-preserving random selection, keyed `sum()` callbacks, and iterable Arr helpers even where Laravel's guide is silent. - **Implementation:** Eager and lazy keying now normalize enum and stringable objects consistently; lazy key lookup tracks requested keys instead of counting yields; and keyed sums forward both callback arguments. The shared collection contract now includes `reduceInto()`, truthful key-preserving random results, numeric sum accumulation, and conditional integer results for callback-free `times()`. Arr keeps native array fast paths while correctly streaming Traversables for `first()`, `every()`, and `some()`; callback-free `last()` retains only the final value, while callback-based `last()` retains ordered key/value occurrences so it can preserve reverse callback order and short-circuiting without collapsing duplicate keys. The impossible ArrayAccess mutation was removed from `Arr::push()`. Lazy iterator normalization accepts both IteratorAggregate and plain Iterator sources, including generators, without changing scalar-returning callable runtime support. Deprecated aliases and their stale public docs/tests were removed with intentional-omission markers. Split-package metadata, named-argument parameter names, public annotations, test signatures, and user documentation now match the supported behavior. - **Regression tests:** Runtime coverage exercises backed-enum keying, duplicate and empty lazy key sets, keyed sums, mutable reduction, preserved random keys, every accepted Arr iterable shape and short-circuit rule, duplicate iterator keys, exact reverse `last()` callback order, ArrayAccess parameter names, and LazyCollection combine/zip with ArrayIterator and generators. Static fixtures cover Arr refinements; eager, lazy, and contract-level random/sum behavior; iterable-returning lazy factories; callback-free and callback-based `times()`; helper conditionals; and the new reduction API. Removal markers keep future parity work from silently restoring deprecated aliases. @@ -1540,7 +1542,8 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved mandatory stored depth, bigint/integer/native-UUID-or-compatible/ULID schema helpers, the three-index layout, explicit concrete scopes for structural diagnostics and scalar lookups, stored named integrity categories, bounded worker-static trait metadata, protected evented descendant deletion, and the measured write/storage cost required for much faster reads and better scope isolation. Existing useful Laravel/Eloquent-shaped APIs remain; Aimeos is the ongoing source reference but not a parity constraint. - **Important rejected concerns:** Do not add a package lock, retry/backoff, timeout, schema-introspection cache, optional-depth fallback, default depth index, widened bounds, self-FK, arbitrary ID-type selector, scope registry, restore stack, worker-retained tree index, global result sort/re-sort, pairwise crossing join, whole-tree PHP scanner, scalar-ID encoder, unconditional movement refresh, implicit relation cache, default per-descendant events, or compatibility layer for the former schema/results. These either duplicate framework/database ownership, regress measured write or memory behavior, or address no supported failure. - **Implementation:** Schema macros and split discovery now create typed parents, stored depth, and exact scoped indexes. Model actions, builders, relations, collections, repair/rebuild, and diagnostics use one scope/key/structural-query model; structural writes maintain bounds, parentage, and depth together. Exact mutation-identity hydration, deferred-target revalidation, immutable persisted scope, owned rebuild fields, usable partial-relation boundaries, and current subtree-root checks prevent partial or stale models from supplying false structure. Numeric node-state reads reject incomplete bounds, while eager relations filter eligible parents before deduplication and skip the query when none remain. Real sibling relations, adaptive eager matching, iterative collection linking, exact restore/freshness publication, revision-aware weak model observations, evented-deletion hooks, and bounded trait metadata replace stale or incomplete paths. Repair/rebuild select complete scope identity and assign internally owned rows without per-row reloads. Dead properties, overrides, constants, helpers, imports, comments, fixture resets, integrity categories, and obsolete suppressions are removed. -- **Cross-package revalidation:** `database-10` is complete for Nested Set: schema helpers and all structural paths use the pooled Database connection/builder lifecycle without retaining connection objects or bypassing supported query construction. `nested-set-13` updates Testing's authoritative static-state subscriber and its regression; the later full Testing audit must preserve this cleanup registration. Eloquent's existing soft-delete and custom-builder metadata remain the single owners instead of gaining package caches. +- **Cross-package revalidation:** `database-10` is complete for Nested Set: schema helpers and all structural paths use the pooled Database connection/builder lifecycle without retaining connection objects or bypassing supported query construction. `nested-set-13` updates Testing's authoritative static-state subscriber and its regression. Eloquent's existing soft-delete and custom-builder metadata remain the single owners instead of gaining package caches. +- **Later Testing revalidation:** `nested-set-13` remains registered in the authoritative subscriber and covered through its exact static-state cleanup regression. - **Regression tests:** Unit and SQLite/Testbench coverage proves all scalar/model key shapes, exact scopes and stores, model date-format normalization without connection resolution, logical connection/table freshness and copied-context isolation, custom builders, event-free connection-preserving existence aliases, qualified/nested relation queries, sibling relations, adaptive eager matching, partial/complete same-row eager parents in both orders, exact projection requirements, query-free and queried-empty eager loads, truthful node-state reads, ordered collection linking, every movement/deletion mode including partial structural projections, deferred revalidation, scope immutability and isolation, missing rows, stored depth, integrity categories, repair/rebuild root and payload ownership, rollback/veto behavior, bounded consecutive-mutation and recursive-create identity reloads, absence of repair/rebuild identity-reload N+1s, trait caching, cleanup, and schema macro re-registration. Driver-routed MySQL, MariaDB, PostgreSQL, and SQLite integration coverage proves exact parent types/index order, integer and UUID trees, scoped UUID soft-delete restoration, scope isolation, portable composite diagnostics and compound ordering, and persisted subtree depth. A final assertion-validity sweep removed dead/unrelated cases and made every corrected regression falsify its owning boundary. - **Performance and complexity:** Benchmarks selected operation-local scope indexes and the three database indexes over Aimeos's retained global index and the former quadratic matcher. Against the former Hypervel matcher, large eager matches fall from hundreds or thousands of milliseconds to tens. Against Aimeos's global index, the final design retains about 6 MiB instead of roughly 37–45 MiB. Representative scoped reads improve materially on every supported driver; structural index writes cost about 1.6x on MySQL and 1.3x on PostgreSQL in the measured worst cases, with MariaDB/SQLite neutral or faster. Revision-aware observation reduces exact structural reloads from six to three for the measured consecutive append and from fifteen to eight for four-child recursive creation. Repair/rebuild no longer scales exact-row reloads with tree size, and entirely ineligible eager loads now avoid their relation query. Ordinary model checks gain one bounded static lookup; a mutated logical tree gains one coroutine-local revision state and weak observation lookup, with no network call, lock, retry, worker-retained tree, or hidden serialization. - **Public result:** Nested Set remains Eloquent-shaped and adds modern schema, typed-key, sibling-relation, stored-depth, repair, rebuild, and diagnostic APIs. Bulk deletion remains the scalable default. Transactions and application-owned serialization remain required for concurrent writes to one tree. Native UUID columns are supported where the database does so, including PostgreSQL and supported MariaDB versions. Integrity diagnostics require MySQL 8.0 or newer; every other Hypervel-supported database meets their window-function requirement at its framework minimum. @@ -1709,6 +1712,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Important rejected concerns:** Do not add transport retries, health checks, poisoned-resource policy, attachment streaming machinery, Filesystem capability/reflection wrappers, pool-proxy magic forwarding, fake delay state, callback-order modes, a notification-specific duplicate ordering test, a Support package-graph redesign, or SES v1 compatibility. Retain intentional Markdown fallbacks and the protected Laravel provider extension point. Failed attachment-data reads have no valid fallback and continue to fail at the existing typed/Symfony boundary. - **Implementation and boundaries:** Mail now renders before callbacks, owns explicit delayed queue selection, resolves storage adapters once, handles unknown MIME without violating string contracts, forwards every non-empty SES v2 tenant name exactly, and constructs on-demand transports only from supplied configuration. MailFake owns every side-effecting fake entry point and consumes explicit selection once, including failure paths. Current assertion, facade, callback, URL, queue, attachment, and dependency surfaces are restored at their existing owners. Dead fallback reads, false concrete annotations, broad suppressions, direct Notifications/Testing dependencies, and stale documentation were removed. - **Cross-package revalidation:** `support-28` through `support-31` are complete in Support; the remaining EventFake test-parity gap is recorded in `docs/todo.md`. `support-29` also revalidates Validation's existing URL rule without completing its later package audit. `contracts-10` is complete across Contracts and Mail; `contracts-11` is complete across Contracts, Mail, and Console. `filesystem-14` is complete across storage attachments and ServeFile. `http-27` changes documentation only. No consumer-local workaround was added. +- **Later Testing revalidation:** `testing-06` keeps both public Mailable ordered rendered-text assertions on the shared constraint, restores truthful `list` PHPDocs and upstream destructuring, and pins the valid string `"0"` through both callers without changing Mail's runtime ownership. - **Regression tests:** Counterfactual coverage proves explicit delayed and enum queue names at every alias; direct and mailable callbacks observe and may replace rendered bodies; HTTP(S) and single-label attachment URLs; single storage resolution and false MIME fallback; exact SES tenant behavior including `"0"`; one-shot fake selection across ordinary, queued, invalid, and failing calls; named recipients; zero real transport or queue leakage; and concrete Mail facade exposure. Additional validation covers split metadata, generated Notification facade metadata, worker-safe temporary-file ownership, and Console's scheduled-output consumer. Current MailFake, NotificationFake, attachment, message, queued-mailable, SES, and all existing Mail integration suites were ported or revalidated. - **Performance and complexity:** Callback ordering moves existing work without adding work. Storage attachments remove duplicate disk resolution and metadata I/O when MIME is explicit. Fake, metadata, docs, tests, and SES option copying are cold or test-only; queue and URL fixes add only constant-time normalization/validation at their existing boundaries. No request-time lock, retry, registry, cache, context slot, new container loop, network preflight, retained request state, or meaningful hot-path overhead is introduced. - **Laravel-facing result:** Supported current Laravel Mail APIs, named arguments, queue identifier forms, callbacks, assertions, facade forwarding, and protected extension points are preserved or restored. Hypervel intentionally supports Amazon SES through SES v2 only and keeps pooled mailers behind `TransportPoolProxy`; both differences are documented without a compatibility shim. Type corrections reject only values outside the real supported Laravel behavior. @@ -1895,7 +1899,8 @@ Append package entries in checklist order. Keep each entry compact but complete - **Architecture and worker ownership:** Factory, BladeCompiler, EngineResolver, component metadata, compiled-template names, and verified freshness remain worker-lived. Render state and transient compiler state remain coroutine-local. Every compile pass initializes its current-section, footer, and protected raw-block owners, then clears raw blocks and component hashes on exit. Component hashes are not reset at entry because their public facade API may seed the immediately following pass. No compiler stack, snapshot, or clone was added. Inline publication stays lock-free at the existing atomic Filesystem replacement boundary. - **Correctness and parity:** Failed renders now clear loop frames before they can corrupt later loop metadata. Slot cleanup closes a retained-state leak—no later render reads stale slot state, but rendered slot HTML and `ComponentSlot` objects no longer remain in coroutine context for the rest of the request. Failed or incomplete compilations no longer retain raw template bytes or component hashes in the active context, and successful opening-only component compilations are bounded as well. Components resolve current nested, slot, enum, attribute, and directive forms. Inline templates recognize legitimate empty files and repair incomplete files. Compiled views honor disabled caching, recover after verified-file deletion, and always pop diagnostic path state. Top-level directive parsing preserves commas in conditions, stack names, nested expressions, and inline JSON arrays; `@json` no longer silently replaces its `JSON_HEX_*` defaults and weakens attribute-context escaping. Standard cleanup resets all Component and CompilerEngine static state without exposing the new Component mutator to templates. Provider visibility, named arguments, protected compiler contracts, facades, dependencies, and current integration scenarios are restored. `@elsePushIf`, exact inline publication, loop cleanup, directory-boundary roots, worker/coroutine echo ownership, render-before-store section content, exhaustive path cleanup, complete directive splitting, and compile-state failure cleanup intentionally correct behavior beyond current Laravel without removing a useful Laravel API. - **Important rejected concerns and closed limitations:** Do not add publication locks, retries, polling, file watchers, cache eviction, a section stack, compiler/Factory clones, request-scoped services, render-state snapshots, compile-state snapshots, placeholder registries, parsed-expression caches, directive grammar objects, or compatibility aliases. A component hash seeded through the public API must be consumed by the immediately following compile pass; arbitrary intervening passes are not preserved because doing so would retain stale stack state. Directive splitting uses one compile-time PHP-token pass whenever an expression contains a comma; comma counts cannot safely identify top-level separators. Class components still require their explicit alias before data, so no undocumented two-argument class grammar or synthesized alias was added. Dynamic inline templates still retain one fixed-size key and compiled file per distinct template; bounding cardinality would require unjustified eviction machinery. The opaque parent-placeholder salt is immutable worker state and needs no test reset because stored sections are already cleared. Compiled output deliberately names the base Factory, so overriding the protected salt on a Factory subclass is not a complete cached-template extension point. `@parent` inside a separately compiled include no longer depends on cold-cache ordering; that unsupported pattern consistently uses the include's empty compile-pass placeholder instead of freezing whichever outer section happened to compile first. -- **Cross-package revalidation:** The complete View surface retains `view-01` request overlay precedence/restoration and revalidates `reflection-02` through closure inference and component/compiler reflection. Foundation owns `view-09` and `view-24`; its canonical config and command tests cover both. Testing's authoritative subscriber invokes View's standardized `view-37` cleanup hooks. Testbench's duplicate framework-reset registry is deleted; its raw PHPUnit base owns exception-handler restoration through the same protected seam and exhaustive failure ordering as the Components base. Foundation documents that caller-driven exception because it uniquely needs the active test case, and Testing covers both base classes' cleanup-failure path. Boost's reserved-keyword list matches the complete base Component filter. The later full Boost, Testbench, and Testing audits are indexed. View removes its false Foundation/Validation requirements while retaining optional Foundation directives through Composer `suggest`. The byte-identical Translation stringable boundary is separately owned as `translation-10`; View's sibling boundary is complete under `view-28` without editing the active Translation worktree. +- **Cross-package revalidation:** The complete View surface retains `view-01` request overlay precedence/restoration and revalidates `reflection-02` through closure inference and component/compiler reflection. Foundation owns `view-09` and `view-24`; its canonical config and command tests cover both. Testing's authoritative subscriber invokes View's standardized `view-37` cleanup hooks. Testbench's duplicate framework-reset registry is deleted; its raw PHPUnit base owns exception-handler restoration through the same protected seam and exhaustive failure ordering as the Components base. Foundation documents that caller-driven exception because it uniquely needs the active test case, and Testing covers both base classes' cleanup-failure path. Boost's reserved-keyword list matches the complete base Component filter. The later full Boost and Testbench audits are indexed. View removes its false Foundation/Validation requirements while retaining optional Foundation directives through Composer `suggest`. The byte-identical Translation stringable boundary is separately owned as `translation-10`; View's sibling boundary is complete under `view-28` without editing the active Translation worktree. +- **Later Testing revalidation:** `view-37` retains the centralized View cleanup calls and both framework base classes' cleanup-failure coverage; Foundation and Testbench remain targeted corrections with Testbench's full audit still pending. - **Upstream-ready defects:** Current Laravel shares the naive comma splitting in `@json`, conditional stacks, and once-only push/prepend directives. Multi-key inline JSON can silently lose the default `JSON_HEX_*` flags; the stack forms emit malformed argument boundaries for literal or nested commas. These sites are ready for an owner-coordinated upstream report; no external report is part of this branch. - **Implementation and cleanup:** Compiler, layout, component, engine, provider, Factory, finder, and package boundaries now use their final ownership model. The obsolete footer property, parent-placeholder context map/getter, validation middleware, duplicate defaults, stale dependencies, superseded compiled files through marker `v3`, and inaccurate docs/comments are removed. Facades are generated from the corrected concrete methods rather than edited by hand. - **Regression tests:** Counterfactual coverage proves slot/loop cleanup, exact inline publication—including the complete-file no-write branch—and bounded keys, nested/unnamed/enum components, conditional and once-only stack parsing with literal/nested commas and generated IDs, HTML-safe JSON options and depth, complete static resets and bare-component data keys, sequential and concurrent compile ownership, raw-block and component-hash cleanup after caught failures, successful opening-only hash cleanup, the public immediate seed-then-compile sequence, nested/failing/concurrent echo overrides including Mail, footer/end-directive contracts, lazy defaults, verified-fresh cache behavior, exhaustive compiled-path cleanup, provider/config/metadata/facade contracts, root-safe cache traversal, and current full-app View behavior. The protected slot-context assertion pins retained-state cleanup because no output path rereads stale slots. The Xdebug-only `ParseError` branches are contained at their source boundaries without adding a synthetic test seam; ordinary incomplete expressions cover the trailing-token guard. @@ -2227,3 +2232,28 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and complexity:** Disabled Telescope now installs less instrumentation. Request byte checks are cheaper, exception aggregation reduces writes, monitored tags retain one bulk insert, ordered deletes use indexed columns, event prefixes use one direct check, EventWatcher no longer resolves its dispatcher per recorded event, and schedule reconciliation mutates the pending object without an extra query. Enabled view reflection occurs only for recorded views with no per-view container lookup. DumpWatcher performs one cache read only on explicit non-`always` dumps. Other guards are constant-time cold telemetry checks; no request path gains a lock, retry, poll, network round trip, unbounded allocation, or meaningful performance regression. - **Validation and review:** Changed focused suites, complete Telescope coverage, affected sibling tests, frontend install/audit/build review, generated-asset provenance, formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, dogfood, and stale scans passed at the applicable checkpoints. `git diff --check` reports only the intentional trailing whitespace in the generated Highlight.js PHP grammar at `src/telescope/dist/app.js:59`; it is load-bearing regex-class content and must not be normalized. Post-gate corrections passed targeted and complete affected coverage. Independent review verified final behavior, API/performance boundaries, counterfactual tests, and records with no remaining finding. - **Assessment:** Telescope is coroutine-safe, worker-lifecycle-safe, query- and storage-correct, failure-truthful, current at the audited Laravel surface, production-suitable when enabled, and cheaper when disabled. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale path, meaningful hot-path regression, unintended Laravel API break, or unresolved defect. + +### Complete Testing correctness, parallel ownership, and current parity + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh full-diff self-review, and independent code review are signed off. The audit covered Testing source and tests, parent-worker and callback ownership, Testbench's worker-local clone, command/profile resource publication, response/view/component/mail assertions, console fakes, PHPUnit cleanup, split/root metadata, canonical public documentation, current Laravel source/tests and originating changes, installed PHPUnit 13 and ParaTest 7 contracts, and every routed consumer. The detailed design is recorded in [`2026-08-08-2225-testing-correctness-parallel-ownership-and-current-parity.md`](2026-08-08-2225-testing-correctness-parallel-ownership-and-current-parity.md). + +| Findings | Final decision | +|---|---| +| `testing-03`, `testing-04` | Track exactly the process tokens whose setup callback was entered, preserve setup/runner failure precedence, and exhaust owned process and test-case teardown callbacks while retaining the first teardown failure. | +| `testing-05` | Give `artisan test` one explicit resource boundary, require complete PHPUnit-config and profile publication, release every owned file/directory, and preserve the primary or first cleanup failure. | +| `testing-06` | Use one normalized rendered-HTML constraint across Response, View, and Component; restore their current assertion APIs, truthful list PHPDocs, and canonical HTTP/View guidance; keep raw ordered assertions separate; and correct string-zero, malformed-encoding, textless-value, and duplicate-diagnostic defects, including both Mail callers. | +| `testing-07` | Restore current bulk JSON-path, canonicalized-path, missing-path, and missing flashed-input assertions through existing single-item owners. | +| `testing-08` | Preserve numeric-string forbidden-output keys under strict types, clear all shared console expectations and bindings after every attempted command, remove dead table state, and make documented `dd()` behavior capture plain output, report the real exit code, and execute once. | +| `testing-09`, `testing-10` | Restore logged-exception, redirect, and JSON failure-context precedence without secondary type failures; accept every valid JSON root and memoize one validated decoded wrapper per response. | +| `testing-11`, `testing-12` | Require a callable public static test-state registrar and port bounded matching, recursive-trait, exception-metadata, grouped-session diagnostics, and keyed-null session semantics at their existing owners. | +| `testing-13`, `testing-16` | Correct Testing split dependencies, suggestions, provenance, README metadata, and executable checks; remove guaranteed-core JSON/Hash extension inventory from every active root/split manifest and stale plan while retaining genuinely optional extensions. | +| `testing-14`, `testing-15` | Complete truthful test method types and restore every touched environment value, worker-clone file, profile file, process argument, and test-owned fixture exactly on success or failure. | + +- **Architecture and ownership:** Normal tests still receive one fresh coroutine from the inherited framework base cases. `AfterEachTestSubscriber` remains the sole framework-static cleanup registry, and Testbench still owns one disposable application clone per worker. The parent parallel coordinator keeps its protected setup loop extension point, but one local list bounded by the number of setup callbacks that loop enters drives exact fresh-application teardown. Setup remains fail-fast; only failure cleanup is exhaustive. Tests continue to own only the files, processes, external resources, and process-global fixtures they create. +- **Cross-package completion:** `http-01`, `reflection-04`, `config-02`, `bus-17`, `http-server-03`, `http-server-05`, `database-08`, `database-15`, `nested-set-13`, `view-37`, `testing-01`, and `testing-02` are revalidated and no longer carry Testing-pending wording. `testing-06` revalidates Mail's two public ordered rendered-text assertions. `testing-16` revalidates every active Auth, Broadcasting, Encryption, Filesystem, Fortify, Horizon, Passkeys, Routing, Sanctum, Socialite, Telescope, and Testing manifest through Testing's global scan; updated package-specific metadata tests additionally pin Auth, Horizon, Passkeys, Routing, Sanctum, Telescope, and Testing. +- **Laravel-facing result:** Supported current Laravel Testing assertion, command, fake, named-argument, method-order, and protected extension APIs are preserved or restored. Hypervel retains streamed binary handling, HEAD producer suppression, coroutine-per-test execution, centralized static cleanup, and its Testbench/ParaTest ownership. `ParallelRunner::getExitCode()` remains the one documented omission because supported ParaTest 7 returns the final integer directly from `RunnerInterface::run()`. Deliberate upstream-defect corrections are limited to safe one-shot `dd()` output, contained unsupported exception context, correct rendered-text and diagnostics, valid string zero, malformed bytes, and keyed-null session presence semantics. +- **Important rejected concerns:** No persistent token registry, teardown scheduler, aggregate-exception system, callback-priority API, generic resource stack, atomic-publication framework, process supervisor, assertion hierarchy, DOM parser, registrar interface/reflection cache, fixture manager, production reset hook, compatibility wrapper, or ParaTest 8 speculation was added. The existing Foundation/Testing package cycle was not rearranged, and active Composer metadata retains every extension the supported PHP runtime can actually omit. +- **Regression coverage:** Deterministic tests pin partial setup ownership, overridden token sets, fresh application ordering, runner suppression and both failure-precedence rules; exhaustive process/test teardown; every command allocation/report/signal/cleanup edge and failed/short publication; Response/View/Component/Mail assertion orientation, malformed bytes, string zero, exact diagnostics and model identity; bulk response APIs; console reuse and one-shot subprocess debugging; failure-context precedence; every JSON root and memoized identity; registrar shapes; grouped-session diagnostics; exact fixture restoration; and root/split metadata. +- **Performance and complexity:** No application request, worker loop, database, Redis, network, or production coroutine path changes. Successful parallel startup creates the same applications in the same order and adds only a bounded local token list. Exhaustive iteration occurs only after failures. JSON memoization removes repeated parsing; matching and trait discovery remove test-only allocations. Assertion normalization, publication checks, metadata validation, typing, and fixture restoration run only in tests or commands. No lock, retry, poll, sleep, cache, context slot, retained worker map, or meaningful runtime overhead is introduced. +- **Validation and review:** Every changed file was exercised through its focused suite; affected Testing, Console, Foundation, Mail, Testbench, and metadata coverage is green. Canonical assertion, view, console-test, and Testing-package documentation was checked against the final public surfaces. Root/split Composer validation, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, stale-symbol scans, and `git diff --check` passed. Fresh caller/callee, token/application ownership, cleanup, API, fixture, retained-state, hot-path, dead-code, and overengineering review is complete, and independent code review signed off with no remaining implementation finding. +- **Assessment:** Testing now has exact parallel/resource ownership, current assertion and response parity, failure-truthful diagnostics, bounded metadata, and exception-safe fixture cleanup. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale path, production hot-path regression, unintended Laravel API break, or unresolved accepted defect. diff --git a/docs/plans/2026-08-05-1615-auth-correctness-lifecycle-and-current-parity.md b/docs/plans/2026-08-05-1615-auth-correctness-lifecycle-and-current-parity.md index d66284f7f..2ce0c12a3 100644 --- a/docs/plans/2026-08-05-1615-auth-correctness-lifecycle-and-current-parity.md +++ b/docs/plans/2026-08-05-1615-auth-correctness-lifecycle-and-current-parity.md @@ -470,7 +470,6 @@ Add one human-facing sentence to `src/boost/docs/verification.md`: links expire Add the remaining direct runtime dependencies to `src/auth/composer.json`, sorted under the existing package convention: ```json -"ext-hash": "*", "hypervel/console": "^0.4", "hypervel/notifications": "^0.4", "hypervel/queue": "^0.4", diff --git a/docs/plans/2026-08-05-2352-routing-correctness-current-parity-and-cache-lifecycles.md b/docs/plans/2026-08-05-2352-routing-correctness-current-parity-and-cache-lifecycles.md index 11fac8dbc..7e93d85b4 100644 --- a/docs/plans/2026-08-05-2352-routing-correctness-current-parity-and-cache-lifecycles.md +++ b/docs/plans/2026-08-05-2352-routing-correctness-current-parity-and-cache-lifecycles.md @@ -639,7 +639,6 @@ Declare only the proven Routing split dependencies: { "require": { "ext-filter": "*", - "ext-hash": "*", "hypervel/auth": "^0.4", "hypervel/prompts": "^0.4", "hypervel/redis": "^0.4", diff --git a/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md b/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md index 6a833db72..29790474f 100644 --- a/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md +++ b/docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md @@ -486,7 +486,7 @@ No config validation subsystem or second JSON pass is added. ### 10. Complete metadata, public docs, and provenance -In the split manifest add direct runtime dependencies: `ext-ctype`, `ext-filter`, `ext-json`, +In the split manifest add direct runtime dependencies: `ext-ctype`, `ext-filter`, `hypervel/cookie`, `hypervel/foundation`, `hypervel/session`, and `symfony/http-foundation`. `symfony/console` already exists. Add Sanctum's provider to root package discovery, not `DefaultProviders`. Add executable root/split metadata coverage. diff --git a/docs/plans/2026-08-07-2205-passkeys-correctness-security-and-maintenance.md b/docs/plans/2026-08-07-2205-passkeys-correctness-security-and-maintenance.md index 402b53846..cdb69eb40 100644 --- a/docs/plans/2026-08-07-2205-passkeys-correctness-security-and-maintenance.md +++ b/docs/plans/2026-08-07-2205-passkeys-correctness-security-and-maintenance.md @@ -201,7 +201,6 @@ source-verifiable. Add these direct requirements to `src/passkeys/composer.json`: ```json -"ext-hash": "*", "hypervel/context": "^0.4", "nesbot/carbon": "^3.13.1", "symfony/http-kernel": "^8.1" diff --git a/docs/plans/2026-08-08-1443-telescope-correctness-current-parity-and-watcher-lifecycles.md b/docs/plans/2026-08-08-1443-telescope-correctness-current-parity-and-watcher-lifecycles.md index e7e5a6d17..9a908b0d7 100644 --- a/docs/plans/2026-08-08-1443-telescope-correctness-current-parity-and-watcher-lifecycles.md +++ b/docs/plans/2026-08-08-1443-telescope-correctness-current-parity-and-watcher-lifecycles.md @@ -515,7 +515,6 @@ This is a boot/runtime performance improvement, not added overhead. Declare direct split-package requirements: ```json -"ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", "guzzlehttp/guzzle": "^7.15.1", diff --git a/docs/plans/2026-08-08-2225-testing-correctness-parallel-ownership-and-current-parity.md b/docs/plans/2026-08-08-2225-testing-correctness-parallel-ownership-and-current-parity.md new file mode 100644 index 000000000..087da3808 --- /dev/null +++ b/docs/plans/2026-08-08-2225-testing-correctness-parallel-ownership-and-current-parity.md @@ -0,0 +1,891 @@ +# Testing correctness, parallel ownership, and current parity + +**Status:** Complete. + +## Objective + +Complete the Testing audit by correcting verified parallel-runner ownership, teardown exhaustion, +test-command resource publication, assertion behavior, current Laravel API gaps, console command +isolation, JSON roots, registrar validation, split metadata, and test fixture ownership. Preserve +Hypervel's centralized static cleanup, coroutine-per-test model, worker-local Testbench clone, +ParaTest 7 runner contract, live token/config behavior, streamed binary responses, and HEAD producer +suppression. + +These paths run in test workers, assertions, or `artisan test`; no application request path changes. +The design adds no production registry, retry, lock, coroutine context, cache, middleware, process +supervisor, resource stack, scalar assertion hierarchy, or compatibility wrapper. + +## Evidence baseline + +- Hypervel branch baseline: `0.4` at `dae01c405705a5a87606b6b9abb6217cac46884f`. +- Current Laravel framework reference: `examples/laravel/framework` `13.x` at + `8df67f9d176d1d0375a866d8c6780be95ce0336e`. +- Installed test dependencies: PHPUnit `13.x` and ParaTest `7.24`; ParaTest 8 is not claimed. +- The `.tmp/audit-findings/testing.md` report is discovery evidence only. Every accepted item was + rechecked against current source, tests, callers, dependency ownership, and upstream history. +- Originating Laravel changes were used to discover the complete changed-file surface; current + Laravel source/tests remain the porting reference: + +| PR | Current result used here | +|---|---| +| `#59140`, `#59161`, `#60090`, `#60128` | HTML assertion APIs, normalization, diagnostics, tests | +| `#59829`, `#60225`, `#59970` | Bulk JSON-path and missing flashed-input assertions | +| `#60816` | Forbidden output keyed by `"0"`; Hypervel also restores string keys before strict matcher calls | +| `#56838`, `#56849` | `PendingCommand::dd()` API; its two upstream defects are corrected | +| `#51725` | Logged exception, redirect-error, JSON-error assertion context | +| `#58946` | Grouped ordinary session-value diagnostics | +| `#60745`, `#59452` | Fluent matching and recursive-trait allocation cleanup | +| `#60423`, `#60514` | Truthful `JsonException` metadata | +| `#60929` | Per-response decoded JSON memoization | + +- PHP's current installation contract makes JSON always available from PHP 8.0 and Hash always + available from PHP 7.4; Filter remains disableable. The official references are + [JSON installation](https://www.php.net/manual/en/json.installation.php), + [Hash installation](https://www.php.net/manual/en/hash.installation.php), and + [Filter installation](https://www.php.net/manual/en/filter.installation.php). Other declared + extensions also remain real optional constraints. +- Completed routed work remains authoritative: `http-01`, `reflection-04`, `config-02`, `bus-17`, + `http-server-03`, `http-server-05`, `database-08`, `nested-set-13`, `view-37`, `testing-01`, and + `testing-02`, plus Testing's side of `database-15`, are revalidated, not replaced. + +## Anti-overengineering rules + +The following wording is retained verbatim from the core audit plan. Its principle numbering is +also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this +plan” refers to the core plan's +[Established remediation vocabulary](2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md#established-remediation-vocabulary) +section. + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Retained architecture and API boundaries + +- Normal tests remain isolated by the inherited coroutine-enabled base cases; no test gains its own + coroutine trait or a new per-package base class. +- `AfterEachTestSubscriber` remains the one framework-static cleanup registry. Package tests own + only files, processes, external resources, and process-global fixtures they create. +- `PhpHandler` remains before parallel setup because it applies PHPUnit configuration, including + `HYPERVEL_PARALLEL_TESTING`, before callbacks inspect parallel state. +- `RunsInParallel::forEachProcess()` remains the protected setup-loop extension point; its default + implementation delegates each token to the new protected single-process owner. Teardown uses + that owner directly over the exact tokens whose setup callback was entered, including a custom + token set produced by an override. +- Every parent-coordinator process callback still receives a fresh application and a string token + in ascending configured process order. Testbench's application resolver remains honored. +- Process setup remains fail-fast; only teardown becomes exhaustive. Test-case and process setup + callback semantics do not change. +- ParaTest's installed `RunnerInterface::run(): int` contract remains the runner boundary. + Laravel's public `getExitCode()` compatibility method remains intentionally omitted because the + supported ParaTest interface has no such method and already returns the final integer directly. +- Testbench's disposable application clone remains worker-local and shared across that worker's + tests, so tests still restore every file they mutate within it. +- `TestResponse` keeps binary streamed-response handling, cached streamed content, and HEAD + producer suppression. New JSON memoization sits above those existing boundaries. +- `TestView`/`TestComponent` macro cleanup and recent exact-object/stored-model identity behavior + remain intact. +- Supported Laravel public APIs, method order, and named arguments remain compatible except for + the recorded ParaTest-only `getExitCode()` omission. `forEachProcess()` retains its protected + name/signature and setup role, but teardown deliberately uses the exact attempted-token owner + rather than invoking the overridable loop a second time. Deliberate upstream-defect corrections + are limited to safe/one-shot `dd()` output, unsupported or empty exception-context containment, + rendered-text string-zero assertions, malformed-encoding normalization, textless expected-value + failures, correctly formatted constraint messages, and keyed-null session assertions retaining + their presence-and-not-null contract. PendingCommand's numeric-string cast is a Hypervel + strict-types correction, not an upstream defect. + +## Findings and final decisions + +Final IDs continue after existing `testing-01` and `testing-02`. + +| Final ID | Audit source | Final decision | +|---|---|---| +| `testing-03` | `testing-audit-01` | Track setup-entered tokens locally; teardown every owned token and preserve first/primary failure. | +| `testing-04` | `testing-audit-02` | Exhaust process/test-case teardown callbacks in registration order; retain the first error. | +| `testing-05` | `testing-audit-03` | Give `artisan test` one resource boundary and require complete file publication. | +| `testing-06` | `testing-audit-04` | Port one normalized HTML-text constraint and consistent Response/View/Component APIs; correct string-zero, malformed-encoding, textless-value, and diagnostic defects in the shared constraints. | +| `testing-07` | `testing-audit-05` | Restore current bulk JSON-path and missing flashed-input APIs. | +| `testing-08` | `testing-audit-06` | Correct forbidden output, failure cleanup, and `dd()` output/one-shot execution. | +| `testing-09` | `testing-audit-07` | Restore failure-context precedence and contain unsupported logged context values. | +| `testing-10` | `testing-audit-08`, Laravel `#60929` | Accept every valid JSON root and memoize one decoded wrapper per response. | +| `testing-11` | `testing-audit-09` | Require an explicitly defined callable public static registrar method. | +| `testing-12` | `testing-audit-10` | Port four bounded current-parity cleanups and preserve keyed-null session semantics. | +| `testing-13` | `testing-audit-11` | Correct Testing split dependencies, suggestions, metadata test, and provenance. | +| `testing-14` | `testing-audit-12` | Add required `void` types to remaining direct/touched test methods, file by file. | +| `testing-15` | `testing-audit-13` | Restore exact environment and worker-clone file state after every direct test path. | +| `testing-16` | Owner-approved metadata correction | Remove vacuous `ext-json`/`ext-hash` constraints from every active manifest and stale plan. | + +## Implementation + +Work one file at a time. Do not use scripts, search-and-replace, `sed`, `awk`, or loops to modify +the remaining test signatures or manifests. Run every changed/new test file immediately. + +### 1. Make parent process ownership transactional (`testing-03`) + +In `src/testing/src/Concerns/RunsInParallel.php`: + +- remove unused `$output`; +- import `ApplicationContract`, `RunnerInterface`, and `WrapperRunner` and replace the file's three + remaining fully qualified class references; +- keep `PhpHandler` first; +- retain `forEachProcess()` as the protected loop extension point and have its default + implementation delegate each token to one protected `forProcess()` owner, which overrides can + also reuse. That owner constructs one application, applies one string-token resolver, invokes + its callback, then resets the resolver before independently flushing that application while + preserving callback-first failure precedence. The resolver reset is a plain assignment and + needs no impossible-failure guard; +- in setup, append the token to a local list as the callback's first statement. A partially failing + setup is owned; application/token failures before callback entry are not; +- skip the runner after setup failure; +- teardown setup-entered tokens in ascending order with fresh applications, continuing after every + failure; +- add a concise WHY above teardown explaining that rerunning the overridable setup loop cannot + guarantee the same owned token set; +- preserve the setup/runner error over cleanup errors; otherwise throw the first teardown error; +- deliberately throw a teardown-only failure after an otherwise successful runner. + +The controlling shape is: + +```php +$attemptedTokens = []; +$exception = null; +$exitCode = RunnerInterface::EXCEPTION_EXIT; + +try { + $this->forEachProcess(function () use (&$attemptedTokens): void { + $attemptedTokens[] = (string) ParallelTesting::token(); + ParallelTesting::callSetUpProcessCallbacks(); + }); + + $exitCode = $this->runner->run(); +} catch (Throwable $throwable) { + $exception = $throwable; +} + +foreach ($attemptedTokens as $token) { + try { + $this->forProcess($token, fn () => ParallelTesting::callTearDownProcessCallbacks()); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } +} + +if ($exception !== null) { + throw $exception; +} + +return $exitCode; +``` + +At the matching upstream position, add a concise source comment explaining that Laravel's +`getExitCode()` fallback has no applicable method on supported ParaTest 7. Retain fresh application +counts, direct resolver reset, and guarded application flush inside `forProcess()`. No +matching Laravel Testing regression covers this method, so no test is skipped and no `REMOVED:` +marker applies. + +Update `tests/Testing/ParallelRunnerTest.php` through `execute()`, using a stub runner and queued +application fakes. Prove callback entry, partial ownership, unattempted exclusion, runner +suppression, all-token teardown, both failure-precedence rules, resolver/flush exhaustion, string +token ordering, fresh applications, and teardown-only propagation. A subclass regression must +prove an overridden `forEachProcess()` drives setup and the attempted token set while teardown +uses the default `forProcess()` owner over exactly those tokens. Snapshot and restore: + +- `$_SERVER['HYPERVEL_PARALLEL_TESTING']`; +- `$_ENV`/process values changed by `PhpHandler` (`COLUMNS`, `LINES`); +- ambient `TEST_TOKEN` and resolver callbacks; +- the global Container instance, restored only by class teardown; +- static application/runner resolvers. + +### 2. Exhaust only teardown callback lists (`testing-04`) + +In `src/testing/src/ParallelTesting.php`, route only +`callTearDownProcessCallbacks()` and `callTearDownTestCaseCallbacks()` through a private helper: + +```php +private function callTearDownCallbacks(array $callbacks, array $parameters): void +{ + $exception = null; + + foreach ($callbacks as $callback) { + try { + $this->container->call($callback, $parameters); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } +} +``` + +Call it inside the existing `whenRunningInParallel()` boundary with the same named parameters. +Do not alter setup callback loops. Extend `tests/Testing/ParallelTestingTest.php` for both teardown +lists, registration order, multiple errors/first-error propagation, and a setup fail-fast control. + +### 3. Give `artisan test` one resource boundary (`testing-05`) + +In `src/testing/src/Console/TestCommandBase.php`: + +- leave no-resource preflight exits before ownership; +- begin the owner before profile directory allocation and argument construction; +- include profile allocation, temporary PHPUnit config creation, Process construction/run, + reporting, and coverage reporting; +- retain current SIGINT swallowing and propagate non-SIGINT signals; +- after every path, independently attempt temporary-config cleanup, allocated coverage cleanup, + then profile-directory cleanup; +- preserve the operation error, otherwise the first cleanup error. + +Use direct `try/catch/finally` code in `handle()`, not a generic resource stack. Cleanup order and +precedence must remain obvious in that method. + +Require temporary configuration publication before returning its path: + +```php +$written = @$document->save($this->temporaryConfigurationFile); + +if ($written === false) { + throw new RuntimeException(sprintf( + 'Unable to write temporary PHPUnit configuration [%s].', + $this->temporaryConfigurationFile, + )); +} +``` + +The suppression is narrow: the checked return becomes the contextual exception and avoids an +extra native warning for the same failure. + +In `src/testing/src/Profile/ExecutionFinishedSubscriber.php`, encode once and require a full write: + +```php +$encoded = json_encode($slowTests, JSON_THROW_ON_ERROR); +$written = @file_put_contents($path, $encoded); + +if ($written !== strlen($encoded)) { + throw new RuntimeException(sprintf('Unable to write test profile [%s].', $path)); +} +``` + +Both write suppressions are narrowly paired with exact checked results and contextual exceptions; +they do not swallow a failure. + +Extend `tests/Testing/Console/TestCommandTest.php` with existing-method harness failures for +argument construction, non-SIGINT process failure, reporting, coverage cleanup, and competing +cleanup errors. Verify every owned file/directory is removed and exact primary/first-cleanup +precedence. Skip the permission-based temporary-configuration publication regression when running +as root, where permission checks are unreliable. Add +`tests/Testing/Profile/ExecutionFinishedSubscriberTest.php`; a local test stream +wrapper may deterministically return false/short writes without adding a production seam. Register +and unregister it in `try/finally`. + +### 4. Normalize rendered HTML assertions once (`testing-06`) + +Copy current Laravel `Constraints/SeeInHtml.php`, then apply Hypervel strict types and correct its +verified assertion defects. Normalize valid Unicode and retain byte-wise behavior for malformed +encodings that current Hypervel assertions accept: + +```php +protected function normalize(string $value): string +{ + $value = trim(html_entity_decode(strip_tags($value), ENT_QUOTES, 'UTF-8')); + $normalized = preg_replace('/\s+/u', ' ', $value); + + if ($normalized !== null) { + return $normalized; + } + + /** @var string $normalized */ + $normalized = preg_replace('/\s+/', ' ', $value); + + return $normalized; +} +``` + +Use it for ordinary, ordered, and negative rendered-text assertions in `TestResponse`, `TestView`, +and `TestComponent`. In both constraints, replace `empty($value)` with `$value === ''` so the valid +string `"0"` is asserted rather than skipped. In `SeeInHtml` only, fail before searching when a +non-empty raw expectation normalizes to empty; re-normalize `$failedValue` in the cold +`failureDescription()` path to select the distinct `the expected value "..." contains visible +text` diagnostic. Keep raw empty-string skipping unchanged. + +Preserve `SeeInOrder` for raw content and Mailable assertions. It needs no normalized-empty branch +because entity decoding cannot turn a non-empty value into an empty string. Remove the duplicated +`Failed asserting that` prefix and every terminal period from both constraints' failure fragments; +PHPUnit supplies the prefix and final period. Assert complete rendered messages so doubled +punctuation cannot pass unnoticed. Delete now-dead `TestResponse::decodedResponseText()`. + +Retain current Laravel's non-redundant `string|list` and `list` parameter PHPDocs +across the complete assertion family on Response/View/Component. The native `array|string` and +`array` types cannot express element types. For View/Component, the resulting API includes: + +```php +public function assertSee(array|string $value, bool $escape = true): static; +public function assertSeeHtml(array|string $value): static; +public function assertSeeHtmlInOrder(array $values): static; +public function assertSeeText(array|string $value, bool $escape = true): static; +public function assertSeeTextInOrder(array $values, bool $escape = true): static; +public function assertDontSee(array|string $value, bool $escape = true): static; +public function assertDontSeeHtml(array|string $value): static; +public function assertDontSeeText(array|string $value, bool $escape = true): static; +``` + +Add `tests/Testing/SeeInHtmlTest.php` from current upstream and extend +`tests/Testing/TestResponseTest.php` plus `tests/Testing/TestViewTest.php` for array APIs, raw HTML, +entity/tag/ASCII and Unicode whitespace normalization, ordering, negation, and unchanged model +identity behavior. Cover `TestComponent` in the same focused file with an inline component/view +fixture; do not add a new base class. Add counterfactual coverage for string zero, whitespace-only +expectations, raw markup-only expectations with `escape: false`, malformed UTF-8 matching and +non-matching, and exact PHPUnit diagnostics. Because upstream's constraint tests usually invert +the production operands, include one production-oriented assertion that requires constructor +content normalization. + +Add Hypervel-owned `list` PHPDocs to Mailable's two ordered assertion methods and prove +string-zero behavior through both public callers in `tests/Mail/MailMailableAssertionsTest.php`. +Each caller must reject an absent zero and pin zero as the failed ordered value; a present-zero +success case alone is not counterfactual when the broken constraint skips zero. Restore the three +touched plain-text assertion destructures to upstream's `[, $text]` form. Run targeted PHPStan on +both constraint files. Do not share the two intentionally different normalizers or extract a +value-filter helper. + +Update both duplicated public surfaces: `src/boost/docs/http-tests.md` and +`src/boost/docs/views.md`. Document the expanded `TestComponent` surface beside its example. + +### 5. Restore bounded TestResponse APIs (`testing-07`) + +Insert current methods in upstream relative order, using existing single-item assertions only: + +```php +public function assertJsonPaths(array $paths): static +{ + foreach ($paths as $path => $expected) { + $this->assertJsonPath($path, $expected); + } + + return $this; +} + +public function assertJsonPathsCanonicalizing(array $paths): static +{ + foreach ($paths as $path => $expected) { + $this->assertJsonPathCanonicalizing($path, $expected); + } + + return $this; +} + +public function assertJsonMissingPaths(array $paths): static +{ + foreach ($paths as $path) { + $this->assertJsonMissingPath($path); + } + + return $this; +} +``` + +`assertSessionMissingInput(array|string $key): static` recurses over arrays and otherwise asserts +`session()->hasOldInput($key) === false` with Laravel's diagnostic. Port current regressions into +`tests/Testing/TestResponseTest.php`. Update `src/boost/docs/http-tests.md`, including one concise +canonicalizing example; add no matcher or registry. + +### 6. Correct PendingCommand isolation and debug execution (`testing-08`) + +In `src/testing/src/PendingCommand.php`: + +- use `array_search(...) !== false` for exact and substring forbidden output; +- normalize each forbidden-output array key back to `string` once before constructing its exact or + substring matcher, then capture that normalized key for write-back. PHP converts canonical + numeric-string associative keys to integers; add one concise WHY and do not replace the public + arrays or Laravel's shared Mockery scalar matching; +- use `===` for string question text; +- after `mockConsoleOutput()` succeeds, put execution, translation, exit assertions, and + verification in `try/finally`; +- first reset the shared `expectsOutput` scalar to `null`, then clear every expectation array and + remove the `OutputStyle` binding in that `finally`; +- preserve the original thrown failure; +- port `dd(): never` between `run()` and `verifyExpectations()`, but correct both upstream defects. +- remove the dead public `expectedTables` property from `InteractsWithConsole` and its flush line; + `expectsTable()` lowers tables into ordinary output expectations, and neither framework has any + reader or writer for that property. + +The final debug method is: + +```php +public function dd(): never +{ + $this->hasExecuted = true; + + $output = new BufferedOutput; + $consoleOutput = new OutputStyle(new ArrayInput($this->parameters), $output); + $exitCode = $this->app + ->make(KernelContract::class) + ->call($this->command, $this->parameters, $consoleOutput); + + dd([ + 'exitCode' => $exitCode, + 'output' => $output->fetch(), + ]); +} +``` + +`BufferedOutput` intentionally captures plain text. It neither reads nor closes process `STDOUT`. +Setting `hasExecuted` before the call prevents `__destruct()` from running the command again after +`dd()` exits. + +Extend `tests/Console/ArtisanCommandTest.php` for exact and substring forbidden key `"0"` and +caught command/exit/verification failures. Immediately assert `expectsOutput` is `null`, every +expectation array is empty, and the `OutputStyle` binding is absent, then run a clean second command +as end-to-end proof. Avoid required Mockery invocations on deliberately failing paths. Add +counterfactual sequential-command regressions for both no-argument states: after +`doesntExpectOutput()`, a second command's explicit line matcher must still run; after +`expectsOutput()`, a silent second command with no output matcher must remain valid. Let normal +Mockery teardown expose either stale state. Restore Laravel's named +`verifyMockeryExpectationsNow()` test helper for the four tests that deliberately force immediate +Mockery verification; retain the bounded `ignoringMockOnceExceptions()` helper and call sites. +Add a non-discovered +`tests/Console/Fixtures/PendingCommandDdFixture.php` and invoke it in a subprocess: assert command +text appears in dumped `output`, the real command exit code appears in the dump, the dump reaches +stdout, the expected nonzero dump exit occurs, and a temporary side-effect counter proves one +command execution. Explain the fixture's inherited-scope `$APP_BASE_PATH` handoff with one concise +WHY. Delete the temporary counter file in `finally`; the committed fixture remains. Preserve +Laravel's `Debug the command.` method title. +Type every test method in the heavily modified file `: void`; retain purposeful Mockery-verification helpers rather than +adding or blindly removing `m::close()` calls. + +Update `src/boost/docs/console-tests.md` beside existing command debugging guidance. + +### 7. Restore assertion failure context safely (`testing-09`) + +In `src/testing/src/TestResponseAssert.php`, port Laravel's precedence: + +1. last collected logged exception; +2. redirect-session validation errors; +3. JSON errors. + +Foundation's producer stays unchanged because supported log context includes strings. At the +failure-only consumer boundary: + +```php +$lastException = $this->response->exceptions->last(); + +if ($lastException instanceof Throwable || (is_string($lastException) && $lastException !== '')) { + return $this->appendExceptionToException($lastException, $exception); +} +``` + +Type `appendExceptionToException(Throwable|string $exceptionToAppend, ...)` and remove Laravel's +contradictory `@param Throwable`. Unsupported mixed values and the empty string fall through to +redirect/JSON context instead of causing a secondary error or empty diagnostic that hides the +original assertion failure. Containing unsupported mixed values is the approved upstream-defect +correction; empty-string fallthrough restores Laravel's truthy-guard behavior. Do not scan backward +or normalize arbitrary log context. + +Test throwable/non-empty-string context, unsupported mixed values, empty-string fallthrough, +redirect errors, JSON errors, and exact precedence in `tests/Testing/TestResponseTest.php`. +Revalidate existing Foundation construction +coverage in `tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php` without changing the +producer. + +### 8. Accept every valid JSON root and parse once (`testing-10`) + +In `src/testing/src/AssertableJsonString.php`, change only decoded storage to `mixed`; the accepted +input union remains unchanged. In `TestResponse`, add: + +```php +protected ?AssertableJsonString $decodedResponseJson = null; +``` + +Capture streamed/ordinary content once, build one wrapper, and validate before publishing it: + +```php +if ($this->decodedResponseJson !== null) { + return $this->decodedResponseJson; +} + +$content = $this->isStreamedResponse() + ? $this->streamedContent() + : $this->getContent(); +$testJson = new AssertableJsonString($content); +$decodedResponse = $testJson->json(); + +// JSON permits only space, tab, line feed, and carriage return around a value. +if ($decodedResponse === null && trim($content, " \t\n\r") !== 'null') { + if ($this->exception) { + throw $this->exception; + } + + PHPUnit::withResponse($this)->fail('Invalid JSON was returned from the route.'); +} + +return $this->decodedResponseJson = $testJson; +``` + +`false` is valid and must not enter the invalid branch. Do not call `json_validate()`, decode +twice, accept NUL/vertical-tab as whitespace, or assign the cache before validation. + +Extend `tests/Testing/TestResponseTest.php` for `false`, `true`, integer, string, `null`, valid JSON +whitespace, invalid null-like bytes, stored response-exception precedence, streamed content, +existing arrays/objects, and strict wrapper identity across repeated decodes. Represent the root +cases as a list of content/expected pairs so PHP cannot coerce JSON text into colliding array keys. + +### 9. Validate test-state registrar callability (`testing-11`) + +Keep class-string and `class_exists` validation, then require both explicit definition and +callability: + +```php +if (! method_exists($class, 'register') || ! is_callable($class . '::register')) { + throw new RuntimeException( + "Test-state registrar [{$class}] declared by [{$source}] must define a public static register method." + ); +} +``` + +Extend `tests/Testing/PHPUnit/TestStateRegistrarsTest.php` with public-instance, +protected/private-static, abstract-static, magic-only negatives and inherited-public-static +positive coverage. Do not introduce reflection, an interface, instantiation, deduplication, or a +discovery cache. + +### 10. Apply bounded current parity and preserve keyed-null semantics (`testing-12`) + +In `TestResponse::assertSessionHasAll()`, keep integer keys, Closures, and keyed null expectations +on their existing paths, but group ordinary non-null values. Current Laravel accidentally compares +a missing key's null result with the null sentinel that means “present and not null”, allowing a +missing key to pass: + +```php +$actual = []; +$expected = []; + +// Populate both only for ordinary named, non-null values. A null expectation delegates to +// assertSessionHas() because it asserts that the key is present and not null. + +if ($expected !== []) { + PHPUnit::withResponse($this)->assertEquals($expected, $actual); +} +``` + +Test missing and wrong ordinary values so the combined equality diagnostic is pinned. Also prove +that a keyed null expectation fails for an absent key and a present-null key, but passes for a +present non-null key. + +In `Fluent/Concerns/Matching.php`, replace the intermediate +`whereInstanceOf('Closure')->isNotEmpty()` allocation with the current direct +`contains(fn ($value) => $value instanceof Closure)` check. This also removes the class name +encoded as an unqualified string. In +`Concerns/TestDatabases.php`, consume `class_uses_recursive()` directly without `array_flip()`. +Add truthful `@throws JsonException` to `assertStreamedJsonContent()` and +`assertSessionHasNoErrors()`. Run their existing focused tests after each file. + +### 11. Correct Testing split metadata and provenance (`testing-13`) + +Update `src/testing/composer.json`: + +```json +"require": { + "ext-dom": "*", + "ext-mbstring": "*", + "hypervel/di": "^0.4", + "nesbot/carbon": "^3.13.1" +}, +"suggest": { + "brianium/paratest": "Required to run tests in parallel (^7.24).", + "phpunit/phpunit": "Required to use Hypervel's testing assertions and PHPUnit integration (^13.0.3)." +} +``` + +The excerpt shows additions/wording, not the complete manifest. Retain all existing dependencies. +`hypervel/di` is direct because the unconditional centralized subscriber calls its AOP/class-map +owners. Carbon and mbstring are direct runtime boundaries. The existing Foundation↔Testing cycle +is inspected and retained: both packages already expose broad public cross-package behavior, and +moving it would change ownership/namespaces without fixing a defect. + +Add `tests/Testing/PackageMetadataTest.php` matching root constraints, provider discovery, +required direct packages/extensions, suggestion ranges, and absence of guaranteed-core extension +requirements. Keep the `src/testing/README.md` package header and prevailing DeepWiki badge, then +add the applicable entries in standard package order: + +```md +Documentation: https://hypervel.org/docs/testing + +## Differences From Laravel + +- `ParallelRunner` does not expose Laravel's `getExitCode()` method. ParaTest 7 returns the final + exit code directly from `RunnerInterface::run()`, and Hypervel's `execute()` method returns it. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Testing +``` + +### 12. Remove guaranteed-core extension metadata everywhere (`testing-16`) + +The durable rule is: Composer platform requirements express facilities a supported runtime may +lack; they do not inventory every extension/function used. Remove `ext-json` and `ext-hash` because +PHP `^8.4` guarantees both. Keep `ext-filter`, `ext-ctype`, and every other declared extension that +PHP can omit. + +Use `composer remove ext-json ext-hash --no-update` for root `composer.json`, then +`composer update --lock` to refresh the local untracked lock. Edit these split manifests one at a +time: + +```text +src/auth/composer.json +src/broadcasting/composer.json +src/encryption/composer.json +src/filesystem/composer.json +src/fortify/composer.json +src/horizon/composer.json +src/passkeys/composer.json +src/routing/composer.json +src/sanctum/composer.json +src/socialite/composer.json +src/telescope/composer.json +``` + +Remove `require` entries and the two misleading `suggest` entries in Broadcasting/Filesystem. +Update and immediately run: + +```text +tests/Auth/PackageMetadataTest.php +tests/Horizon/PackageMetadataTest.php +tests/Passkeys/PackageMetadataTest.php +tests/Routing/PackageMetadataTest.php +tests/Sanctum/PackageMetadataTest.php +tests/Telescope/PackageMetadataTest.php +``` + +Remove stale extension declarations/claims from: + +```text +docs/plans/2026-07-01-0915-fortify-passkeys-port.md +docs/plans/2026-08-05-1615-auth-correctness-lifecycle-and-current-parity.md +docs/plans/2026-08-05-2352-routing-correctness-current-parity-and-cache-lifecycles.md +docs/plans/2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md +docs/plans/2026-08-07-2205-passkeys-correctness-security-and-maintenance.md +docs/plans/2026-08-08-1443-telescope-correctness-current-parity-and-watcher-lifecycles.md +``` + +Have `tests/Testing/PackageMetadataTest.php` scan the root and every active split manifest so +reintroducing either guaranteed-core extension as a requirement or suggestion fails directly. +Do not edit `_archive/`: it is a parked historical snapshot, not an active/published package. +Afterward grep all active manifests/tests/plans for both names, run `composer validate` as an +explicit metadata check, and run the normal final gate separately. + +### 13. Finish test typing and fixture ownership (`testing-14`, `testing-15`) + +Add `: void` manually to remaining test methods in: + +```text +tests/Testing/AssertTest.php +tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php +tests/Testing/Concerns/TestCachesTest.php +tests/Testing/Concerns/TestDatabasesTest.php +tests/Testing/Concerns/TestViewsTest.php +tests/Testing/Fluent/AssertTest.php +tests/Testing/ParallelConsoleOutputTest.php +tests/Testing/ParallelTestingTest.php +tests/Console/ArtisanCommandTest.php +``` + +Do not change providers or inherited special methods. Run each file before moving to the next. + +In `TestCachesTest`, distinguish key absence from a present falsey/string value and restore the +exact original state: + +```php +$hadValue = array_key_exists('HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE', $_SERVER); +$original = $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] ?? null; + +try { + $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] = '1'; + // assertion +} finally { + if ($hadValue) { + $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] = $original; + } else { + unset($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE']); + } +} +``` + +In `TestCommandTest`, snapshot presence and exact bytes for worker-clone `phpunit.xml` and +`custom-phpunit.xml`; restore old bytes or delete newly created files in teardown. Delete every +`.hypervel-phpunit-profile-*.xml` created by the class, including the second generated path. Keep +the standalone profile project cleanup and one class-level exact `argv` restoration; do not +duplicate that ownership inside individual tests. Do not add a production reset hook, blanket +environment snapshot, filesystem transaction, or global fixture manager. + +### 14. Update audit records + +Route Testing as the active package and name this detail plan. Required revalidation entries are +the completed IDs listed in the evidence baseline. + +After implementation and review: + +- recheck every proposed `testing-*` ID against latest `0.4` and renumber collisions before use; +- add one compact ledger work unit named `Complete Testing correctness, parallel ownership, and + current parity`, covering `testing-03` through `testing-16`, approved upstream-defect + corrections, rejected designs, routed revalidation, API/performance effects, and validation; +- add `testing-16` to the cross-package dependency index for Auth, Broadcasting, Encryption, + Filesystem, Fortify, Horizon, Passkeys, Routing, Sanctum, Socialite, and Telescope, all + revalidated by their exact metadata tests/manifest review in this work unit; +- route `testing-06` to Mail in the dependency index and cross-reference it from Mail's completed + ledger entry; the two public Mailable ordered assertions are revalidated by their exact test; +- remove Testing's pending wording from every carried row revalidated here; +- mark `testing` complete in the package checklist; +- preserve every carried pending revalidation and completed record from latest `0.4` while + advancing the active routing lines to Testing; +- do not copy review history, branch/commit details, test counts, or `.tmp` prose into tracked + records. + +## Planned file inventory + +| Area | Files | +|---|---| +| Parallel ownership | `RunsInParallel.php`, `ParallelTesting.php`, `ParallelRunnerTest.php`, `ParallelTestingTest.php` | +| Command resources | `TestCommandBase.php`, profile subscriber, `TestCommandTest.php`, new profile subscriber test | +| HTML/assertions | new `SeeInHtml.php`/test, `SeeInOrder.php`, `TestResponse.php`, `TestView.php`, `TestComponent.php`, `Mailable.php`, and focused Testing/Mail tests | +| Console assertions | `PendingCommand.php`, `InteractsWithConsole.php`, `ArtisanCommandTest.php`, new non-discovered `dd()` fixture, console-test docs | +| Response diagnostics/JSON | `TestResponseAssert.php`, `AssertableJsonString.php`, `TestResponse.php`, focused response tests, and Foundation's MakesHttpRequests test | +| Registrar/parity | `TestStateRegistrars.php`/test, `Matching.php`/test, `TestDatabases.php`/test | +| Testing metadata/docs | Testing manifest, README, new metadata test, HTTP/View docs | +| Core-extension metadata | root plus 11 split manifests, six metadata tests, six current plan documents | +| Direct typing/fixtures | eight direct Testing files; TestCaches/TestCommand ownership paths | +| Records | this detail plan, core audit plan, companion ledger | + +The inventory is a verification boundary, not permission for incidental rewrites. If an unexpected +defect appears while touching a file, follow the required investigation/consensus process. + +## Test and validation plan + +| Surface | Counterfactual proof | +|---|---| +| Parent runner | Partial setup owns cleanup; unattempted tokens do not; runner skip/order/fresh apps and failure precedence are exact. | +| Callback lists | Later teardown callbacks run after one/multiple failures; first failure wins; setup still stops. | +| Test command | Every allocation/failure/signal/report/cleanup path releases all owned resources without replacing the primary error. | +| Profile publication | JSON encodes once; false/short write fails with the named path; no partial publication is accepted. | +| HTML helpers | Entity/tag/ASCII/Unicode whitespace, malformed bytes, string zero, textless values, arrays, order, negation, Mail callers, and exact diagnostics behave consistently. | +| Response APIs | Bulk paths, canonicalized paths, missing paths/input, grouped session diagnostics, and existing identity assertions pass. | +| Pending command | Key `"0"`, caught failure reuse, binding cleanup, stdout dump, captured output, and exactly one execution are pinned. | +| Failure context | Throwable/string/redirect/JSON precedence works; unsupported mixed context cannot hide the original assertion failure. | +| JSON roots | Object, array, every scalar, literal null, invalid bytes, stored exception, streaming, and memoized identity are exact. | +| Registrar | Every callable/non-callable method shape yields the intended registration or contextual error. | +| Metadata | Testing's direct dependencies/suggestions/provenance are exact; guaranteed-core entries are absent from every active manifest. | +| Fixtures/types | Exact prior globals/files survive failures; each manually typed test file remains behaviorally green. | + +Run each changed test file immediately. After coherent slices, run focused Testing, Console, +Foundation response-context, Testbench, and affected package metadata tests. Run `composer validate` +after manifest work. At the final checkpoint run `composer fix` once; if it fails, correct with +targeted checks and then run the failed plus remaining gate entries per `AGENTS.md`. + +After gates pass, perform a fresh full-diff review: trace every caller/callee, token/application +owner, cleanup edge, test interleaving, file/global restoration, API/doc parity, retained state, +dead helper/comment, hot-path effect, and new complexity. Then request independent code review and +loop to signoff before final records and owner pre-commit review. + +## API, performance, and complexity assessment + +- Laravel APIs are additive or restored except for the existing recorded `getExitCode()` omission. + `forEachProcess()` remains protected and controls setup, while exact attempted-token teardown is + intentionally no longer routed through an override. Hypervel-specific streamed, coroutine, + identity, and parallel behavior otherwise remain. +- Approved divergences fix upstream defects only: `dd()` captures output without closing stdout + and marks execution before shutdown; response diagnostics contain unsupported context; text + constraints retain string zero and malformed bytes, reject textless values, and emit one + correctly punctuated PHPUnit message; grouped session diagnostics preserve keyed null's + presence-and-not-null semantics. PendingCommand additionally restores numeric-string array keys + before strict string operations and removes the behaviorally inert `expectedTables` property. +- No application request, worker loop, database, Redis, network, or production coroutine path is + changed. +- Successful parallel startup performs the same number/order of application creations and runner + calls. The local attempted-token list is bounded by the number of setup callbacks the + overridable loop enters. +- Exhaustive work is added only after failures. Setup remains fail-fast. +- JSON memoization reduces repeated parsing; Matching and TestDatabases remove small test-only + allocations. HTML normalization and its malformed-byte fallback run only when a test assertion + is made. +- File-publication checks compare already-produced bytes and add no encoding or I/O pass. +- Metadata, docs, test typing, and fixture restoration have no runtime cost. +- The implementation uses direct loops, `try/finally`, `BufferedOutput`, exact type guards, and + existing single-item assertion APIs. No new general-purpose framework mechanism is introduced. + +## Rejected designs and verified non-findings + +- No persistent token/lifecycle registry, retry, rollback callback API, concurrency, or parallel + teardown scheduler; one local attempted-token list owns this one coordinator call. +- No aggregate exception or teardown priority system; preserve one primary/first failure. +- No per-`PendingCommand` expectation-state redesign. Sequential commands are the supported path, + and resetting the shared scalar and arrays after each run completely fixes that path without a + large public divergence from Laravel. +- No prefixed-key, tuple, or entry-object storage for forbidden output; one loop-local cast restores + the declared string contract without changing public expectation arrays. +- No generic resource stack, atomic-file layer, shutdown hook, process supervisor, or production + failure-injection seam. +- No DOM parser/sanitizer beyond the upstream assertion constraint, no three divergent + Response/View/Component implementations, and no shared normalizer or value-filter helper for + the two constraints' intentionally different behavior. +- No bulk assertion matcher/registry and no scalar JSON assertion hierarchy. +- No Foundation logger filtering: Laravel deliberately supports string exception context, and the + public response collection can contain mixed values. +- No second JSON parse or `json_validate()` pass. +- No registrar interface, reflection hierarchy, instantiation, magic-only acceptance, + deduplication, or cache. +- No ParaTest 8 API claim; installed ParaTest 7 returns the exit code directly. +- No broad dependency expansion for every class-gated optional cleanup target. Only direct + unconditional Testing requirements are added. +- No attempt to unwind the existing Foundation/Testing package cycle; doing so would move public + ownership without correcting behavior. +- No `ext-json`/`ext-hash` usage inventory in Composer. Guaranteed facilities are removed; real + optional extension constraints remain. +- No edits under `_archive/`; it intentionally preserves parked historical source. +- No duplicate static cleanup, per-test coroutine traits, test-local global fixture manager, or + production reset API. +- Existing cache/database/view token suffixing, SQLite classification, migration memoization, + Testbench application resolution, central cleanup, binary streaming, HEAD suppression, and + exact-model identity remain correct and are revalidated. diff --git a/src/auth/composer.json b/src/auth/composer.json index f0c234a2b..67e13b1cf 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -24,7 +24,6 @@ ], "require": { "php": "^8.4", - "ext-hash": "*", "nesbot/carbon": "^3.13.1", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/boost/docs/console-tests.md b/src/boost/docs/console-tests.md index 91132527f..de21e3d9c 100644 --- a/src/boost/docs/console-tests.md +++ b/src/boost/docs/console-tests.md @@ -47,6 +47,13 @@ $this->artisan('example:failing-command')->assertFailed(); The `assertOk` method is also available as an alias of the `assertSuccessful` method. +While debugging a console command test, you may use the `dd` method to execute the command and +dump its exit code and captured output: + +```php +$this->artisan('users:all')->dd(); +``` + ## Input / Output Expectations diff --git a/src/boost/docs/http-tests.md b/src/boost/docs/http-tests.md index 07f92dc0c..3c8dd22d7 100644 --- a/src/boost/docs/http-tests.md +++ b/src/boost/docs/http-tests.md @@ -996,7 +996,9 @@ class ExampleTest extends TestCase } ``` -The `TestView` class provides the following assertion methods: `assertSee`, `assertSeeInOrder`, `assertSeeText`, `assertSeeTextInOrder`, `assertDontSee`, `assertDontSeeText`, `assertViewHas`, `assertViewHasAll`, `assertViewMissing`, and `assertViewEmpty`. +The `TestView` class provides the following assertion methods: `assertSee`, `assertSeeHtml`, `assertSeeInOrder`, `assertSeeHtmlInOrder`, `assertSeeText`, `assertSeeTextInOrder`, `assertDontSee`, `assertDontSeeHtml`, `assertDontSeeText`, `assertViewHas`, `assertViewHasAll`, `assertViewMissing`, and `assertViewEmpty`. + +The `assertSeeText`, `assertSeeTextInOrder`, and `assertDontSeeText` methods compare visible text. HTML tags are removed, entities are decoded, and consecutive whitespace is treated as a single space. If needed, you may get the raw, rendered view contents by casting the `TestView` instance to a string: @@ -1039,6 +1041,8 @@ $view = $this->component(Profile::class, ['name' => 'Taylor']); $view->assertSee('Taylor'); ``` +The `TestComponent` class provides the following assertion methods: `assertSee`, `assertSeeHtml`, `assertSeeInOrder`, `assertSeeHtmlInOrder`, `assertSeeText`, `assertSeeTextInOrder`, `assertDontSee`, `assertDontSeeHtml`, and `assertDontSeeText`. + ## Caching Routes @@ -1140,8 +1144,11 @@ Hypervel's `Hypervel\Testing\TestResponse` class provides a variety of custom as [assertJsonMissingExact](#assert-json-missing-exact) [assertJsonMissingValidationErrors](#assert-json-missing-validation-errors) [assertJsonPath](#assert-json-path) +[assertJsonPaths](#assert-json-paths) [assertJsonPathCanonicalizing](#assert-json-path-canonicalizing) +[assertJsonPathsCanonicalizing](#assert-json-paths-canonicalizing) [assertJsonMissingPath](#assert-json-missing-path) +[assertJsonMissingPaths](#assert-json-missing-paths) [assertJsonStructure](#assert-json-structure) [assertOnlyJsonValidationErrors](#assert-only-json-validation-errors) [assertJsonValidationErrors](#assert-json-validation-errors) @@ -1182,6 +1189,7 @@ Hypervel's `Hypervel\Testing\TestResponse` class provides a variety of custom as [assertSimilarJson](#assert-similar-json) [assertSessionHas](#assert-session-has) [assertSessionHasInput](#assert-session-has-input) +[assertSessionMissingInput](#assert-session-missing-input) [assertSessionHasAll](#assert-session-has-all) [assertSessionHasErrors](#assert-session-has-errors) [assertSessionHasErrorsIn](#assert-session-has-errors-in) @@ -1307,7 +1315,7 @@ $response->assertDontSeeHtml($value); #### assertDontSeeText -Assert that the given string is not contained within the response text. This assertion will automatically escape the given string unless you pass a second argument of `false`. This method will pass the response content to the `strip_tags` PHP function before making the assertion: +Assert that the given string or array of strings is not contained within the response's visible text. This assertion will automatically escape each string unless you pass a second argument of `false`. HTML tags are removed, entities are decoded, and consecutive whitespace is treated as a single space before the assertion is made: ```php $response->assertDontSeeText($value, $escape = true); @@ -1544,6 +1552,18 @@ You may assert that the `name` property of the `user` object matches a given val $response->assertJsonPath('user.name', 'Steve Schoger'); ``` + +#### assertJsonPaths + +Assert that the response contains the given data at each specified path: + +```php +$response->assertJsonPaths([ + 'user.name' => 'Steve Schoger', + 'user.email' => 'steve@example.com', +]); +``` + #### assertJsonPathCanonicalizing @@ -1553,6 +1573,18 @@ Assert that the given path in the response contains all of the expected values w $response->assertJsonPathCanonicalizing($path, array $expectedValue); ``` + +#### assertJsonPathsCanonicalizing + +Assert that each given path contains all of its expected values without considering order: + +```php +$response->assertJsonPathsCanonicalizing([ + 'user.roles' => ['editor', 'administrator'], + 'user.teams' => ['Product', 'Support'], +]); +``` + #### assertJsonMissingPath @@ -1578,6 +1610,15 @@ You may assert that it does not contain the `email` property of the `user` objec $response->assertJsonMissingPath('user.email'); ``` + +#### assertJsonMissingPaths + +Assert that the response does not contain any of the given paths: + +```php +$response->assertJsonMissingPaths(['user.email', 'user.phone']); +``` + #### assertJsonStructure @@ -1936,7 +1977,7 @@ $response->assertSeeInOrder(array $values, $escape = true); #### assertSeeText -Assert that the given string is contained within the response text. This assertion will automatically escape the given string unless you pass a second argument of `false`. The response content will be passed to the `strip_tags` PHP function before the assertion is made: +Assert that the given string or array of strings is contained within the response's visible text. This assertion will automatically escape each string unless you pass a second argument of `false`. HTML tags are removed, entities are decoded, and consecutive whitespace is treated as a single space before the assertion is made: ```php $response->assertSeeText($value, $escape = true); @@ -1945,7 +1986,7 @@ $response->assertSeeText($value, $escape = true); #### assertSeeTextInOrder -Assert that the given strings are contained in order within the response text. This assertion will automatically escape the given strings unless you pass a second argument of `false`. The response content will be passed to the `strip_tags` PHP function before the assertion is made: +Assert that the given strings are contained in order within the response's visible text. This assertion will automatically escape the given strings unless you pass a second argument of `false`. HTML tags are removed, entities are decoded, and consecutive whitespace is treated as a single space before the assertion is made: ```php $response->assertSeeTextInOrder(array $values, $escape = true); @@ -2014,6 +2055,17 @@ $response->assertSessionHasInput($key, function (string $value) { }); ``` + +#### assertSessionMissingInput + +Assert that the session is missing one or more keys from the [flashed input array](/docs/{{version}}/responses#redirecting-with-flashed-session-data): + +```php +$response->assertSessionMissingInput($key); + +$response->assertSessionMissingInput(['name', 'email']); +``` + #### assertSessionHasAll diff --git a/src/boost/docs/views.md b/src/boost/docs/views.md index 9eeccb67c..816dc95d0 100644 --- a/src/boost/docs/views.md +++ b/src/boost/docs/views.md @@ -13,6 +13,7 @@ - [Sharing Data With All Views](#sharing-data-with-all-views) - [View Composers](#view-composers) - [View Creators](#view-creators) +- [Testing Views](#testing-views) - [Optimizing Views](#optimizing-views) @@ -348,6 +349,11 @@ use Hypervel\Support\Facades\View; View::creator('profile', ProfileCreator::class); ``` + +## Testing Views + +Hypervel's test helpers can render views, Blade strings, and components without making an HTTP request. The resulting `TestView` and `TestComponent` instances provide assertions for raw HTML, escaped content, visible text, ordering, and view data. See the [HTTP testing documentation](/docs/{{version}}/http-tests#testing-views) for examples and the complete assertion API. + ## Optimizing Views diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index 6eb337be6..54fcba95f 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -45,7 +45,6 @@ "symfony/http-kernel": "^8.1" }, "suggest": { - "ext-hash": "Required to use the Ably and Pusher broadcast drivers.", "hypervel/redis": "Required to use the Redis broadcast driver (^0.4).", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^7.2)." diff --git a/src/encryption/composer.json b/src/encryption/composer.json index 90fe0b20e..5725e57f7 100644 --- a/src/encryption/composer.json +++ b/src/encryption/composer.json @@ -30,7 +30,6 @@ }, "require": { "php": "^8.4", - "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", "hypervel/config": "^0.4", diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index 85f180b52..0dd7b6173 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -55,7 +55,6 @@ "suggest": { "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", - "ext-hash": "Required to use the Filesystem class.", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-google-cloud-storage": "Required to use the Flysystem Google Cloud Storage driver (^3.25.1).", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", @@ -75,4 +74,4 @@ "dev-main": "0.4-dev" } } -} \ No newline at end of file +} diff --git a/src/fortify/composer.json b/src/fortify/composer.json index be7f70515..33f031e6c 100644 --- a/src/fortify/composer.json +++ b/src/fortify/composer.json @@ -15,7 +15,6 @@ }, "require": { "php": "^8.4", - "ext-json": "*", "chillerlan/php-qrcode": "^6.0", "hypervel/auth": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php index ea79b5d74..1c1ccb2c0 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php @@ -39,11 +39,6 @@ trait InteractsWithConsole */ public array $unexpectedOutputSubstrings = []; - /** - * All of the expected output tables. - */ - public array $expectedTables = []; - /** * All of the expected questions. */ diff --git a/src/horizon/composer.json b/src/horizon/composer.json index 743123675..2b8cfe076 100644 --- a/src/horizon/composer.json +++ b/src/horizon/composer.json @@ -24,7 +24,6 @@ ], "require": { "php": "^8.4", - "ext-json": "*", "ext-mbstring": "*", "ext-pcntl": "*", "ext-posix": "*", diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index f8910126c..6fe6b9f53 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -1229,6 +1229,8 @@ public function assertDontSeeInHtml(string $string, bool $escape = true): static /** * Assert that the given text strings are present in order in the HTML email body. + * + * @param list $strings */ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): static { @@ -1248,7 +1250,7 @@ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): sta */ public function assertSeeInText(string $string): static { - [$html, $text] = $this->renderForAssertions(); + [, $text] = $this->renderForAssertions(); PHPUnit::assertStringContainsString( $string, @@ -1264,7 +1266,7 @@ public function assertSeeInText(string $string): static */ public function assertDontSeeInText(string $string): static { - [$html, $text] = $this->renderForAssertions(); + [, $text] = $this->renderForAssertions(); PHPUnit::assertStringNotContainsString( $string, @@ -1277,10 +1279,12 @@ public function assertDontSeeInText(string $string): static /** * Assert that the given text strings are present in order in the plain-text email body. + * + * @param list $strings */ public function assertSeeInOrderInText(array $strings): static { - [$html, $text] = $this->renderForAssertions(); + [, $text] = $this->renderForAssertions(); PHPUnit::assertThat($strings, new SeeInOrder($text)); diff --git a/src/passkeys/composer.json b/src/passkeys/composer.json index 72fe9ee8f..89ffcd923 100644 --- a/src/passkeys/composer.json +++ b/src/passkeys/composer.json @@ -16,8 +16,6 @@ }, "require": { "php": "^8.4", - "ext-hash": "*", - "ext-json": "*", "hypervel/auth": "^0.4", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", diff --git a/src/routing/composer.json b/src/routing/composer.json index 22dc5a3e7..b1cd8a2e0 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -31,7 +31,6 @@ "require": { "php": "^8.4", "ext-filter": "*", - "ext-hash": "*", "hypervel/auth": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index 296268619..b782a47ad 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -26,7 +26,6 @@ "php": "^8.4", "ext-ctype": "*", "ext-filter": "*", - "ext-json": "*", "nesbot/carbon": "^3.13.1", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", diff --git a/src/socialite/composer.json b/src/socialite/composer.json index 41c2ec970..e951ca486 100644 --- a/src/socialite/composer.json +++ b/src/socialite/composer.json @@ -30,7 +30,6 @@ }, "require": { "php": "^8.4", - "ext-json": "*", "firebase/php-jwt": "^7.0", "guzzlehttp/guzzle": "^7.15.1", "hypervel/collections": "^0.4", diff --git a/src/telescope/composer.json b/src/telescope/composer.json index 96a9b5100..a48eadab2 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -24,7 +24,6 @@ ], "require": { "php": "^8.4", - "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", "guzzlehttp/guzzle": "^7.15.1", diff --git a/src/testing/README.md b/src/testing/README.md index dc387b634..f08765bff 100644 --- a/src/testing/README.md +++ b/src/testing/README.md @@ -2,3 +2,12 @@ Testing for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/testing) + +Documentation: https://hypervel.org/docs/testing + +## Differences From Laravel + +- `ParallelRunner` does not expose Laravel's `getExitCode()` method. ParaTest 7 returns the final + exit code directly from `RunnerInterface::run()`, and Hypervel's `execute()` method returns it. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Testing diff --git a/src/testing/composer.json b/src/testing/composer.json index e08b59c7d..ddedfd055 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -31,8 +31,10 @@ "require": { "php": "^8.4", "ext-dom": "*", + "ext-mbstring": "*", "composer-runtime-api": "^2.2", "mockery/mockery": "1.6.x-dev", + "nesbot/carbon": "^3.13.1", "symfony/console": "^8.1", "symfony/http-foundation": "^8.1", "symfony/process": "^8.1", @@ -44,6 +46,7 @@ "hypervel/contracts": "^0.4", "hypervel/cookie": "^0.4", "hypervel/database": "^0.4", + "hypervel/di": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", @@ -53,7 +56,8 @@ "hypervel/view": "^0.4" }, "suggest": { - "phpunit/phpunit": "Required to use Hypervel\\Testing\\PHPUnit\\AfterEachTestExtension (^13.0)." + "brianium/paratest": "Required to run tests in parallel (^7.24).", + "phpunit/phpunit": "Required to use Hypervel's testing assertions and PHPUnit integration (^13.0.3)." }, "config": { "sort-packages": true diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index da98abc9e..14972c29f 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -27,7 +27,7 @@ class AssertableJsonString implements ArrayAccess, Countable /** * The decoded json contents. */ - protected ?array $decoded = null; + protected mixed $decoded = null; /** * Create a new assertable JSON string instance. diff --git a/src/testing/src/Concerns/RunsInParallel.php b/src/testing/src/Concerns/RunsInParallel.php index 6b3a0e8dc..722434d81 100644 --- a/src/testing/src/Concerns/RunsInParallel.php +++ b/src/testing/src/Concerns/RunsInParallel.php @@ -6,15 +6,19 @@ use Closure; use Hypervel\Contracts\Console\Kernel; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Support\Collection; use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Testing\ParallelConsoleOutput; use ParaTest\Options; +use ParaTest\RunnerInterface; +use ParaTest\WrapperRunner\WrapperRunner; use PHPUnit\TextUI\Configuration\PhpHandler; use RuntimeException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; trait RunsInParallel { @@ -33,15 +37,10 @@ trait RunsInParallel */ protected Options $options; - /** - * The output instance. - */ - protected OutputInterface $output; - /** * The original test runner. */ - protected \ParaTest\RunnerInterface $runner; + protected RunnerInterface $runner; /** * Create a new test runner instance. @@ -55,7 +54,7 @@ public function __construct(Options $options, OutputInterface $output) } $runnerResolver = static::$runnerResolver ?: function (Options $options, OutputInterface $output) { - return new \ParaTest\WrapperRunner\WrapperRunner($options, $output); + return new WrapperRunner($options, $output); }; $this->runner = $runnerResolver($options, $output); @@ -90,38 +89,76 @@ public function execute(): int { (new PhpHandler)->handle($this->options->configuration->php()); - $this->forEachProcess(function () { - ParallelTesting::callSetUpProcessCallbacks(); - }); + $attemptedTokens = []; + $exception = null; + $exitCode = RunnerInterface::EXCEPTION_EXIT; try { - $exitCode = $this->runner->run(); - } finally { - $this->forEachProcess(function () { - ParallelTesting::callTearDownProcessCallbacks(); + $this->forEachProcess(function () use (&$attemptedTokens): void { + $attemptedTokens[] = (string) ParallelTesting::token(); + ParallelTesting::callSetUpProcessCallbacks(); }); + + $exitCode = $this->runner->run(); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + // Re-running the overridable setup loop cannot guarantee the same owned token set. + foreach ($attemptedTokens as $token) { + try { + $this->forProcess($token, fn () => ParallelTesting::callTearDownProcessCallbacks()); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; } return $exitCode; } + // ParaTest 7 returns the final exit code from run() and exposes no getExitCode() method. + /** * Apply the given callback for each process. */ protected function forEachProcess(callable $callback): void { Collection::range(1, $this->options->processes)->each(function ($token) use ($callback): void { - $application = $this->createApplication(); + $this->forProcess((string) $token, $callback); + }); + } - try { - ParallelTesting::resolveTokenUsing(fn () => (string) $token); + /** + * Apply the given callback for one process. + */ + protected function forProcess(string $token, callable $callback): void + { + $application = $this->createApplication(); + $exception = null; - $callback($application); - } finally { - ParallelTesting::resolveTokenUsing(null); - $application->flush(); - } - }); + try { + ParallelTesting::resolveTokenUsing(fn () => $token); + + $callback($application); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + ParallelTesting::resolveTokenUsing(null); + + try { + $application->flush(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } } /** @@ -129,7 +166,7 @@ protected function forEachProcess(callable $callback): void * * @throws RuntimeException */ - protected function createApplication(): \Hypervel\Contracts\Foundation\Application + protected function createApplication(): ApplicationContract { $applicationResolver = static::$applicationResolver ?: function () { $path = Application::inferBasePath() . '/bootstrap/app.php'; diff --git a/src/testing/src/Concerns/TestDatabases.php b/src/testing/src/Concerns/TestDatabases.php index 5d57c9203..e30556690 100644 --- a/src/testing/src/Concerns/TestDatabases.php +++ b/src/testing/src/Concerns/TestDatabases.php @@ -41,7 +41,7 @@ protected function bootTestDatabase(): void }); ParallelTesting::setUpTestCase(function ($testCase) { - $uses = array_flip(class_uses_recursive(get_class($testCase))); + $uses = class_uses_recursive(get_class($testCase)); $databaseTraits = [ Testing\DatabaseMigrations::class, diff --git a/src/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index 7f9ffd091..9bd996b00 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\Process; +use Throwable; abstract class TestCommandBase extends Command { @@ -84,39 +85,37 @@ public function handle(): int $this->clearEnv(); $parallel = (bool) $this->option('parallel'); - - if ($this->option('profile')) { - $this->ensureProfileDirectoryExists($this->profileDirectory()); - } - - $process = (new Process( - command: array_merge( - $this->binary(), - $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options), - ), - env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), - ))->setTimeout(null); + $exception = null; + $exitCode = self::FAILURE; try { - $process->setTty(! $this->option('without-tty')); - } catch (RuntimeException) { - } + if ($this->option('profile')) { + $this->ensureProfileDirectoryExists($this->profileDirectory()); + } - $exitCode = self::FAILURE; + $process = (new Process( + command: array_merge( + $this->binary(), + $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options), + ), + env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), + ))->setTimeout(null); + + try { + $process->setTty(! $this->option('without-tty')); + } catch (RuntimeException) { + } - try { - $exitCode = $process->run(function (string $type, string $line): void { - $this->output->write($line); - }); - } catch (ProcessSignaledException $exception) { - if (extension_loaded('pcntl') && $exception->getSignal() !== SIGINT) { - throw $exception; + try { + $exitCode = $process->run(function (string $type, string $line): void { + $this->output->write($line); + }); + } catch (ProcessSignaledException $processSignaledException) { + if (extension_loaded('pcntl') && $processSignaledException->getSignal() !== SIGINT) { + throw $processSignaledException; + } } - } finally { - $this->cleanupTemporaryConfigurationFile(); - } - try { if ($this->option('profile')) { $this->reportProfile(); } @@ -141,9 +140,30 @@ public function handle(): int )); } } + } catch (Throwable $throwable) { + $exception = $throwable; } finally { - $this->coverage?->cleanup(); - $this->cleanupProfileDirectory(); + try { + $this->cleanupTemporaryConfigurationFile(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + $this->coverage?->cleanup(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + $this->cleanupProfileDirectory(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; } return $exitCode; @@ -526,7 +546,14 @@ protected function profileConfigurationFile(string $file): string $this->temporaryConfigurationFile = dirname($file) . DIRECTORY_SEPARATOR . '.hypervel-phpunit-profile-' . getmypid() . '-' . bin2hex(random_bytes(6)) . '.xml'; - $document->save($this->temporaryConfigurationFile); + $written = @$document->save($this->temporaryConfigurationFile); + + if ($written === false) { + throw new RuntimeException(sprintf( + 'Unable to write temporary PHPUnit configuration [%s].', + $this->temporaryConfigurationFile, + )); + } return $this->temporaryConfigurationFile; } diff --git a/src/testing/src/Constraints/SeeInHtml.php b/src/testing/src/Constraints/SeeInHtml.php new file mode 100644 index 000000000..f0e994806 --- /dev/null +++ b/src/testing/src/Constraints/SeeInHtml.php @@ -0,0 +1,132 @@ +normalize($this->content); + + $position = 0; + + foreach ($values as $value) { + if ($value === '') { + continue; + } + + $normalizedValue = $this->normalize($value); + + if ($normalizedValue === '') { + $this->failedValue = $value; + + return false; + } + + $valuePosition = mb_strpos($normalizedContent, $normalizedValue, $position); + + if ($this->negate) { + if ($valuePosition !== false) { + $this->failedValue = $value; + + return false; + } + + continue; + } + + if ($valuePosition === false || $valuePosition < $position) { + $this->failedValue = $value; + + return false; + } + + if ($this->ordered) { + $position = $valuePosition + mb_strlen($normalizedValue); + } + } + + return true; + } + + /** + * Get the description of the failure. + * + * @param array $values + */ + public function failureDescription($values): string + { + if ($this->normalize((string) $this->failedValue) === '') { + return sprintf( + 'the expected value "%s" contains visible text', + $this->failedValue + ); + } + + if ($this->negate) { + return sprintf( + '\'%s\' does not contain "%s"', + $this->content, + $this->failedValue + ); + } + + return sprintf( + '\'%s\' contains "%s"%s', + $this->content, + $this->failedValue, + $this->ordered ? ' in specified order' : '' + ); + } + + /** + * Normalize the given value. + */ + protected function normalize(string $value): string + { + $value = trim(html_entity_decode(strip_tags($value), ENT_QUOTES, 'UTF-8')); + $normalized = preg_replace('/\s+/u', ' ', $value); + + if ($normalized !== null) { + return $normalized; + } + + /** @var string $normalized */ + $normalized = preg_replace('/\s+/', ' ', $value); + + return $normalized; + } + + /** + * Get a string representation of the object. + */ + public function toString(): string + { + return (new ReflectionClass($this))->name; + } +} diff --git a/src/testing/src/Constraints/SeeInOrder.php b/src/testing/src/Constraints/SeeInOrder.php index 0bb6c1c4a..58651214f 100644 --- a/src/testing/src/Constraints/SeeInOrder.php +++ b/src/testing/src/Constraints/SeeInOrder.php @@ -36,7 +36,7 @@ public function matches($values): bool $position = 0; foreach ($values as $value) { - if (empty($value)) { + if ($value === '') { continue; } @@ -64,7 +64,7 @@ public function matches($values): bool public function failureDescription($values): string { return sprintf( - 'Failed asserting that \'%s\' contains "%s" in specified order.', + '\'%s\' contains "%s" in specified order', $this->content, $this->failedValue ); diff --git a/src/testing/src/Fluent/Concerns/Matching.php b/src/testing/src/Fluent/Concerns/Matching.php index fe4e7c4ba..338a20cf3 100644 --- a/src/testing/src/Fluent/Concerns/Matching.php +++ b/src/testing/src/Fluent/Concerns/Matching.php @@ -191,7 +191,7 @@ public function whereContains(string $key, mixed $expected): static return $actual->containsStrict($search); }); - if ($missing->whereInstanceOf('Closure')->isNotEmpty()) { + if ($missing->contains(fn ($search) => $search instanceof Closure)) { PHPUnit::assertEmpty( $missing->toArray(), sprintf( diff --git a/src/testing/src/PHPUnit/TestStateRegistrars.php b/src/testing/src/PHPUnit/TestStateRegistrars.php index 5b278b228..75a63150f 100644 --- a/src/testing/src/PHPUnit/TestStateRegistrars.php +++ b/src/testing/src/PHPUnit/TestStateRegistrars.php @@ -130,9 +130,9 @@ protected function registerClass(string $source, mixed $class): void ); } - if (! method_exists($class, 'register')) { + if (! method_exists($class, 'register') || ! is_callable($class . '::register')) { throw new RuntimeException( - "Test-state registrar [{$class}] declared by [{$source}] must define a register method." + "Test-state registrar [{$class}] declared by [{$source}] must define a public static register method." ); } diff --git a/src/testing/src/ParallelTesting.php b/src/testing/src/ParallelTesting.php index ccc03d211..f83547f7a 100644 --- a/src/testing/src/ParallelTesting.php +++ b/src/testing/src/ParallelTesting.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Container\Container; use Hypervel\Support\Str; +use Throwable; class ParallelTesting { @@ -199,11 +200,9 @@ public function callSetUpTestDatabaseCallbacks(string $database): void public function callTearDownProcessCallbacks(): void { $this->whenRunningInParallel(function () { - foreach ($this->tearDownProcessCallbacks as $callback) { - $this->container->call($callback, [ - 'token' => $this->token(), - ]); - } + $this->callTearDownCallbacks($this->tearDownProcessCallbacks, [ + 'token' => $this->token(), + ]); }); } @@ -213,15 +212,36 @@ public function callTearDownProcessCallbacks(): void public function callTearDownTestCaseCallbacks(mixed $testCase): void { $this->whenRunningInParallel(function () use ($testCase) { - foreach ($this->tearDownTestCaseCallbacks as $callback) { - $this->container->call($callback, [ - 'testCase' => $testCase, - 'token' => $this->token(), - ]); - } + $this->callTearDownCallbacks($this->tearDownTestCaseCallbacks, [ + 'testCase' => $testCase, + 'token' => $this->token(), + ]); }); } + /** + * Call every teardown callback and preserve the first failure. + * + * @param list $callbacks + * @param array $parameters + */ + private function callTearDownCallbacks(array $callbacks, array $parameters): void + { + $exception = null; + + foreach ($callbacks as $callback) { + try { + $this->container->call($callback, $parameters); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + /** * Get a parallel testing option. */ diff --git a/src/testing/src/PendingCommand.php b/src/testing/src/PendingCommand.php index 231fa5cc2..0cadad5c4 100644 --- a/src/testing/src/PendingCommand.php +++ b/src/testing/src/PendingCommand.php @@ -348,39 +348,61 @@ public function run(): int $mock = $this->mockConsoleOutput(); try { - $exitCode = $this->app - ->make(KernelContract::class) - ->call($this->command, $this->parameters, $mock); - } catch (NoMatchingExpectationException $e) { - if ($e->getMethodName() === 'askQuestion') { - $this->test->fail('Unexpected question "' . $e->getActualArguments()[0]->getQuestion() . '" was asked.'); + try { + $exitCode = $this->app + ->make(KernelContract::class) + ->call($this->command, $this->parameters, $mock); + } catch (NoMatchingExpectationException $e) { + if ($e->getMethodName() === 'askQuestion') { + $this->test->fail('Unexpected question "' . $e->getActualArguments()[0]->getQuestion() . '" was asked.'); + } + + throw $e; + } catch (PromptValidationException) { + $exitCode = Command::FAILURE; } - throw $e; - } catch (PromptValidationException) { - $exitCode = Command::FAILURE; - } + if ($this->expectedExitCode !== null) { + $this->test->assertEquals( + $this->expectedExitCode, + $exitCode, + "Expected status code {$this->expectedExitCode} but received {$exitCode}." + ); + } elseif ($this->unexpectedExitCode !== null) { + $this->test->assertNotEquals( + $this->unexpectedExitCode, + $exitCode, + "Unexpected status code {$this->unexpectedExitCode} was received." + ); + } - if ($this->expectedExitCode !== null) { - $this->test->assertEquals( - $this->expectedExitCode, - $exitCode, - "Expected status code {$this->expectedExitCode} but received {$exitCode}." - ); - } elseif (! is_null($this->unexpectedExitCode)) { - $this->test->assertNotEquals( - $this->unexpectedExitCode, - $exitCode, - "Unexpected status code {$this->unexpectedExitCode} was received." - ); + $this->verifyExpectations(); + + return $exitCode; + } finally { + $this->flushExpectations(); + + $this->app->offsetUnset(OutputStyle::class); } + } - $this->verifyExpectations(); - $this->flushExpectations(); + /** + * Debug the command. + */ + public function dd(): never + { + $this->hasExecuted = true; - $this->app->offsetUnset(OutputStyle::class); + $output = new BufferedOutput; + $consoleOutput = new OutputStyle(new ArrayInput($this->parameters), $output); + $exitCode = $this->app + ->make(KernelContract::class) + ->call($this->command, $this->parameters, $consoleOutput); - return $exitCode; + dd([ + 'exitCode' => $exitCode, + 'output' => $output->fetch(), + ]); } /** @@ -412,11 +434,11 @@ protected function verifyExpectations(): void $this->test->fail('Output does not contain "' . array_first($this->test->expectedOutputSubstrings) . '".'); } - if ($output = array_search(true, $this->test->unexpectedOutput)) { + if (($output = array_search(true, $this->test->unexpectedOutput, true)) !== false) { $this->test->fail('Output "' . $output . '" was printed.'); } - if ($output = array_search(true, $this->test->unexpectedOutputSubstrings)) { + if (($output = array_search(true, $this->test->unexpectedOutputSubstrings, true)) !== false) { $this->test->fail('Output "' . $output . '" was printed.'); } } @@ -444,7 +466,7 @@ protected function mockConsoleOutput() : $argument->getAutocompleterValues(); } - return $argument->getQuestion() == $question[0]; + return $argument->getQuestion() === $question[0]; })) ->andReturnUsing(function () use ($question, $i) { unset($this->test->expectedQuestions[$i]); @@ -508,7 +530,10 @@ private function createABufferedOutputMock() }); } + // PHP converts canonical numeric-string array keys to integers, so restore the public string contract before matching. foreach ($this->test->unexpectedOutput as $output => $displayed) { + $output = (string) $output; + /** @var \Mockery\Expectation $expectation */ $expectation = $mock->shouldReceive('doWrite'); $expectation->atLeast() @@ -521,6 +546,8 @@ private function createABufferedOutputMock() } foreach ($this->test->unexpectedOutputSubstrings as $text => $displayed) { + $text = (string) $text; + /** @var \Mockery\Expectation $expectation */ $expectation = $mock->shouldReceive('doWrite'); $expectation->atLeast() @@ -539,11 +566,11 @@ private function createABufferedOutputMock() */ protected function flushExpectations(): void { + $this->test->expectsOutput = null; $this->test->expectedOutput = []; $this->test->expectedOutputSubstrings = []; $this->test->unexpectedOutput = []; $this->test->unexpectedOutputSubstrings = []; - $this->test->expectedTables = []; $this->test->expectedQuestions = []; $this->test->expectedChoices = []; } diff --git a/src/testing/src/Profile/ExecutionFinishedSubscriber.php b/src/testing/src/Profile/ExecutionFinishedSubscriber.php index a0b518144..a1cdf3216 100644 --- a/src/testing/src/Profile/ExecutionFinishedSubscriber.php +++ b/src/testing/src/Profile/ExecutionFinishedSubscriber.php @@ -36,7 +36,11 @@ public function notify(ExecutionFinished $event): void $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? 'default'; $path = $this->directory . DIRECTORY_SEPARATOR . 'profile-' . $token . '-' . getmypid() . '.json'; + $encoded = json_encode($slowTests, JSON_THROW_ON_ERROR); + $written = @file_put_contents($path, $encoded); - file_put_contents($path, json_encode($slowTests, JSON_THROW_ON_ERROR)); + if ($written !== strlen($encoded)) { + throw new RuntimeException(sprintf('Unable to write test profile [%s].', $path)); + } } } diff --git a/src/testing/src/TestComponent.php b/src/testing/src/TestComponent.php index 0bcb3e60d..13e604faf 100644 --- a/src/testing/src/TestComponent.php +++ b/src/testing/src/TestComponent.php @@ -4,8 +4,10 @@ namespace Hypervel\Testing; +use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; use Hypervel\Testing\Assert as PHPUnit; +use Hypervel\Testing\Constraints\SeeInHtml; use Hypervel\Testing\Constraints\SeeInOrder; use Hypervel\View\Component; use Hypervel\View\View; @@ -38,22 +40,39 @@ public function __construct(Component $component, View $view) } /** - * Assert that the given string is contained within the rendered component. + * Assert that the given string or array of strings are contained within the rendered component. * + * @param list|string $value * @return $this */ - public function assertSee(string $value, bool $escape = true): static + public function assertSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $this->rendered); + } return $this; } + /** + * Assert that the given HTML string or array of HTML strings are contained within the rendered component. + * + * @param list|string $value + * @return $this + */ + public function assertSeeHtml(array|string $value): static + { + return $this->assertSee($value, false); + } + /** * Assert that the given strings are contained in order within the rendered component. * + * @param list $values * @return $this */ public function assertSeeInOrder(array $values, bool $escape = true): static @@ -66,15 +85,29 @@ public function assertSeeInOrder(array $values, bool $escape = true): static } /** - * Assert that the given string is contained within the rendered component text. + * Assert that the given HTML strings are contained in order within the rendered component. * + * @param list $values * @return $this */ - public function assertSeeText(string $value, bool $escape = true): static + public function assertSeeHtmlInOrder(array $values): static { - $value = $escape ? e($value) : $value; + return $this->assertSeeInOrder($values, false); + } - PHPUnit::assertStringContainsString((string) $value, strip_tags($this->rendered)); + /** + * Assert that the given string or array of strings are contained within the rendered component text. + * + * @param list|string $value + * @return $this + */ + public function assertSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; + + PHPUnit::assertThat($values, new SeeInHtml($this->rendered)); return $this; } @@ -82,41 +115,61 @@ public function assertSeeText(string $value, bool $escape = true): static /** * Assert that the given strings are contained in order within the rendered component text. * + * @param list $values * @return $this */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { $values = $escape ? array_map(e(...), $values) : $values; - PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->rendered))); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, true)); return $this; } /** - * Assert that the given string is not contained within the rendered component. + * Assert that the given string or array of strings are not contained within the rendered component. * + * @param list|string $value * @return $this */ - public function assertDontSee(string $value, bool $escape = true): static + public function assertDontSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + } return $this; } /** - * Assert that the given string is not contained within the rendered component text. + * Assert that the given HTML string or array of HTML strings are not contained within the rendered component. * + * @param list|string $value * @return $this */ - public function assertDontSeeText(string $value, bool $escape = true): static + public function assertDontSeeHtml(array|string $value): static { - $value = $escape ? e($value) : $value; + return $this->assertDontSee($value, false); + } + + /** + * Assert that the given string or array of strings are not contained within the rendered component text. + * + * @param list|string $value + * @return $this + */ + public function assertDontSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; - PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->rendered)); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, negate: true)); return $this; } diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index a15bd0c0a..3261c862e 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -24,9 +24,11 @@ use Hypervel\Support\Traits\Tappable; use Hypervel\Support\ViewErrorBag; use Hypervel\Testing\Concerns\AssertsStatusCodes; +use Hypervel\Testing\Constraints\SeeInHtml; use Hypervel\Testing\Constraints\SeeInOrder; use Hypervel\Testing\Fluent\AssertableJson; use Hypervel\Testing\TestResponseAssert as PHPUnit; +use JsonException; use LogicException; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; @@ -66,6 +68,11 @@ class TestResponse implements ArrayAccess */ protected ?string $streamedContent = null; + /** + * The decoded response JSON. + */ + protected ?AssertableJsonString $decodedResponseJson = null; + /** * Create a new test response instance. * @@ -598,6 +605,8 @@ public function assertStreamedContent(string $value): static /** * Assert that the given array matches the streamed JSON response content. + * + * @throws JsonException */ public function assertStreamedJsonContent(array $value): static { @@ -606,6 +615,8 @@ public function assertStreamedJsonContent(array $value): static /** * Assert that the given string or array of strings are contained within the response. + * + * @param list|string $value */ public function assertSee(array|string $value, bool $escape = true): static { @@ -622,6 +633,8 @@ public function assertSee(array|string $value, bool $escape = true): static /** * Assert that the given HTML string or array of HTML strings are contained within the response. + * + * @param list|string $value */ public function assertSeeHtml(array|string $value): static { @@ -630,6 +643,8 @@ public function assertSeeHtml(array|string $value): static /** * Assert that the given strings are contained in order within the response. + * + * @param list $values */ public function assertSeeInOrder(array $values, bool $escape = true): static { @@ -642,6 +657,8 @@ public function assertSeeInOrder(array $values, bool $escape = true): static /** * Assert that the given HTML strings are contained in order within the response. + * + * @param list $values */ public function assertSeeHtmlInOrder(array $values): static { @@ -650,6 +667,8 @@ public function assertSeeHtmlInOrder(array $values): static /** * Assert that the given string or array of strings are contained within the response text. + * + * @param list|string $value */ public function assertSeeText(array|string $value, bool $escape = true): static { @@ -657,32 +676,29 @@ public function assertSeeText(array|string $value, bool $escape = true): static $values = $escape ? array_map(e(...), $value) : $value; - $content = $this->decodedResponseText(); - - foreach ($values as $value) { - PHPUnit::withResponse($this)->assertStringContainsString( - html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'), - $content - ); - } + PHPUnit::withResponse($this)->assertThat($values, new SeeInHtml($this->getContent())); return $this; } /** * Assert that the given strings are contained in order within the response text. + * + * @param list $values */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { $values = $escape ? array_map(e(...), $values) : $values; - PHPUnit::withResponse($this)->assertThat($values, new SeeInOrder(strip_tags($this->getContent()))); + PHPUnit::withResponse($this)->assertThat($values, new SeeInHtml($this->getContent(), true)); return $this; } /** * Assert that the given string or array of strings are not contained within the response. + * + * @param list|string $value */ public function assertDontSee(array|string $value, bool $escape = true): static { @@ -699,6 +715,8 @@ public function assertDontSee(array|string $value, bool $escape = true): static /** * Assert that the given HTML string or array of HTML strings are not contained within the response. + * + * @param list|string $value */ public function assertDontSeeHtml(array|string $value): static { @@ -707,6 +725,8 @@ public function assertDontSeeHtml(array|string $value): static /** * Assert that the given string or array of strings are not contained within the response text. + * + * @param list|string $value */ public function assertDontSeeText(array|string $value, bool $escape = true): static { @@ -714,26 +734,11 @@ public function assertDontSeeText(array|string $value, bool $escape = true): sta $values = $escape ? array_map(e(...), $value) : $value; - $content = $this->decodedResponseText(); - - foreach ($values as $value) { - PHPUnit::withResponse($this)->assertStringNotContainsString( - html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'), - $content - ); - } + PHPUnit::withResponse($this)->assertThat($values, new SeeInHtml($this->getContent(), negate: true)); return $this; } - /** - * Get the response text with HTML entities decoded for plain-text assertions. - */ - protected function decodedResponseText(): string - { - return html_entity_decode(strip_tags($this->getContent()), ENT_QUOTES, 'UTF-8'); - } - /** * Assert that the response is a superset of the given JSON. */ @@ -766,6 +771,34 @@ public function assertJsonPath(string $path, mixed $expect): static return $this; } + /** + * Assert that the expected values and types exist at the given paths in the response. + * + * @param array $paths + */ + public function assertJsonPaths(array $paths): static + { + foreach ($paths as $path => $expected) { + $this->assertJsonPath($path, $expected); + } + + return $this; + } + + /** + * Assert that the given paths in the response contain all of the expected values without looking at the order. + * + * @param array> $paths + */ + public function assertJsonPathsCanonicalizing(array $paths): static + { + foreach ($paths as $path => $expected) { + $this->assertJsonPathCanonicalizing($path, $expected); + } + + return $this; + } + /** * Assert that the given path in the response contains all of the expected values without looking at the order. */ @@ -848,6 +881,20 @@ public function assertJsonMissingPath(string $path): static return $this; } + /** + * Assert that the response does not contain the given paths. + * + * @param list $paths + */ + public function assertJsonMissingPaths(array $paths): static + { + foreach ($paths as $path) { + $this->assertJsonMissingPath($path); + } + + return $this; + } + /** * Assert that the response has a given JSON structure. */ @@ -1050,22 +1097,26 @@ public function assertJsonIsObject(?string $key = null): static */ public function decodeResponseJson(): AssertableJsonString { - if ($this->isStreamedResponse()) { - $testJson = new AssertableJsonString($this->streamedContent()); - } else { - $testJson = new AssertableJsonString($this->getContent()); + if ($this->decodedResponseJson !== null) { + return $this->decodedResponseJson; } + $content = $this->isStreamedResponse() + ? $this->streamedContent() + : $this->getContent(); + $testJson = new AssertableJsonString($content); $decodedResponse = $testJson->json(); - if (is_null($decodedResponse) || $decodedResponse === false) { + // JSON permits only space, tab, line feed, and carriage return around a value. + if ($decodedResponse === null && trim($content, " \t\n\r") !== 'null') { if ($this->exception) { throw $this->exception; } + PHPUnit::withResponse($this)->fail('Invalid JSON was returned from the route.'); } - return $testJson; + return $this->decodedResponseJson = $testJson; } /** @@ -1345,14 +1396,25 @@ public function assertSessionHas(array|string $key, mixed $value = null): static */ public function assertSessionHasAll(array $bindings): static { + $actual = []; + $expected = []; + foreach ($bindings as $key => $value) { if (is_int($key)) { $this->assertSessionHas($value); - } else { + } elseif ($value instanceof Closure || $value === null) { + // A null expectation asserts that the session key is present and not null. $this->assertSessionHas($key, $value); + } else { + $expected[$key] = $value; + $actual[$key] = $this->session()->get($key); } } + if ($expected !== []) { + PHPUnit::withResponse($this)->assertEquals($expected, $actual); + } + return $this; } @@ -1387,6 +1449,29 @@ public function assertSessionHasInput(array|string $key, mixed $value = null): s return $this; } + /** + * Assert that the session is missing a given key in the flashed input array. + * + * @param list|string $key + */ + public function assertSessionMissingInput(array|string $key): static + { + if (is_array($key)) { + foreach ($key as $item) { + $this->assertSessionMissingInput($item); + } + + return $this; + } + + PHPUnit::withResponse($this)->assertFalse( + $this->session()->hasOldInput($key), + "Session has unexpected key [{$key}]." + ); + + return $this; + } + /** * Assert that the session has the given errors. */ @@ -1441,6 +1526,8 @@ public function assertSessionDoesntHaveErrors(array|string $keys = [], ?string $ /** * Assert that the session has no errors. + * + * @throws JsonException */ public function assertSessionHasNoErrors(): static { diff --git a/src/testing/src/TestResponseAssert.php b/src/testing/src/TestResponseAssert.php index 793733e89..0d1038cd8 100644 --- a/src/testing/src/TestResponseAssert.php +++ b/src/testing/src/TestResponseAssert.php @@ -4,6 +4,7 @@ namespace Hypervel\Testing; +use Hypervel\Http\RedirectResponse; use Hypervel\Support\Arr; use PHPUnit\Framework\ExpectationFailedException; use ReflectionProperty; @@ -58,6 +59,20 @@ public static function __callStatic(string $name, array $arguments): void */ protected function injectResponseContext(ExpectationFailedException $exception): ExpectationFailedException { + $lastException = $this->response->exceptions->last(); + + if ($lastException instanceof Throwable || (is_string($lastException) && $lastException !== '')) { + return $this->appendExceptionToException($lastException, $exception); + } + + if ($this->response->baseResponse instanceof RedirectResponse) { + $session = $this->response->baseResponse->getSession(); + + if ($session !== null && $session->has('errors')) { + return $this->appendErrorsToException($session->get('errors')->all(), $exception); + } + } + if ($this->response->baseResponse->headers->get('Content-Type') === 'application/json') { $testJson = new AssertableJsonString($this->response->getContent()); @@ -72,9 +87,11 @@ protected function injectResponseContext(ExpectationFailedException $exception): /** * Append an exception to the message of another exception. */ - protected function appendExceptionToException(Throwable $exceptionToAppend, ExpectationFailedException $exception): ExpectationFailedException + protected function appendExceptionToException(Throwable|string $exceptionToAppend, ExpectationFailedException $exception): ExpectationFailedException { - $exceptionMessage = $exceptionToAppend->getMessage(); + $exceptionMessage = is_string($exceptionToAppend) + ? $exceptionToAppend + : $exceptionToAppend->getMessage(); $exceptionToAppend = (string) $exceptionToAppend; diff --git a/src/testing/src/TestView.php b/src/testing/src/TestView.php index 8f2461d43..7f99e2ea3 100644 --- a/src/testing/src/TestView.php +++ b/src/testing/src/TestView.php @@ -10,6 +10,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; use Hypervel\Testing\Assert as PHPUnit; +use Hypervel\Testing\Constraints\SeeInHtml; use Hypervel\Testing\Constraints\SeeInOrder; use Hypervel\View\View; use Stringable; @@ -117,22 +118,39 @@ public function assertViewEmpty(): static } /** - * Assert that the given string is contained within the view. + * Assert that the given string or array of strings are contained within the view. * + * @param list|string $value * @return $this */ - public function assertSee(string $value, bool $escape = true): static + public function assertSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $this->rendered); + } return $this; } + /** + * Assert that the given HTML string or array of HTML strings are contained within the view. + * + * @param list|string $value + * @return $this + */ + public function assertSeeHtml(array|string $value): static + { + return $this->assertSee($value, false); + } + /** * Assert that the given strings are contained in order within the view. * + * @param list $values * @return $this */ public function assertSeeInOrder(array $values, bool $escape = true): static @@ -145,15 +163,29 @@ public function assertSeeInOrder(array $values, bool $escape = true): static } /** - * Assert that the given string is contained within the view text. + * Assert that the given HTML strings are contained in order within the view. * + * @param list $values * @return $this */ - public function assertSeeText(string $value, bool $escape = true): static + public function assertSeeHtmlInOrder(array $values): static { - $value = $escape ? e($value) : $value; + return $this->assertSeeInOrder($values, false); + } - PHPUnit::assertStringContainsString((string) $value, strip_tags($this->rendered)); + /** + * Assert that the given string or array of strings are contained within the view text. + * + * @param list|string $value + * @return $this + */ + public function assertSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; + + PHPUnit::assertThat($values, new SeeInHtml($this->rendered)); return $this; } @@ -161,41 +193,61 @@ public function assertSeeText(string $value, bool $escape = true): static /** * Assert that the given strings are contained in order within the view text. * + * @param list $values * @return $this */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { $values = $escape ? array_map(e(...), $values) : $values; - PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->rendered))); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, true)); return $this; } /** - * Assert that the given string is not contained within the view. + * Assert that the given string or array of strings are not contained within the view. * + * @param list|string $value * @return $this */ - public function assertDontSee(string $value, bool $escape = true): static + public function assertDontSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + } return $this; } /** - * Assert that the given string is not contained within the view text. + * Assert that the given HTML string or array of HTML strings are not contained within the view. * + * @param list|string $value * @return $this */ - public function assertDontSeeText(string $value, bool $escape = true): static + public function assertDontSeeHtml(array|string $value): static { - $value = $escape ? e($value) : $value; + return $this->assertDontSee($value, false); + } + + /** + * Assert that the given string or array of strings are not contained within the view text. + * + * @param list|string $value + * @return $this + */ + public function assertDontSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; - PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->rendered)); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, negate: true)); return $this; } diff --git a/tests/Auth/PackageMetadataTest.php b/tests/Auth/PackageMetadataTest.php index 69da31cb3..ac50afe80 100644 --- a/tests/Auth/PackageMetadataTest.php +++ b/tests/Auth/PackageMetadataTest.php @@ -35,7 +35,6 @@ public function testDependenciesAndProvidersAreDeclared(): void ); foreach ([ - 'ext-hash', 'nesbot/carbon', 'hypervel/cache', 'hypervel/collections', diff --git a/tests/Console/ArtisanCommandTest.php b/tests/Console/ArtisanCommandTest.php index a143d2e87..85ac4e1c6 100644 --- a/tests/Console/ArtisanCommandTest.php +++ b/tests/Console/ArtisanCommandTest.php @@ -4,18 +4,24 @@ namespace Hypervel\Tests\Console; +use Hypervel\Console\OutputStyle; use Hypervel\Contracts\Console\Kernel; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Artisan; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Console\Fixtures\FakeCommandWithPromptValidation; use Mockery as m; use Mockery\Exception\InvalidCountException; use Mockery\Exception\InvalidOrderException; use PHPUnit\Framework\AssertionFailedError; +use RuntimeException; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Process\Process; class ArtisanCommandTest extends TestCase { - public function testConsoleCommandPasses() + public function testConsoleCommandPasses(): void { Artisan::command('exit', fn () => 0); @@ -23,7 +29,7 @@ public function testConsoleCommandPasses() ->assertOk(); } - public function testConsoleCommandFails() + public function testConsoleCommandFails(): void { Artisan::command('exit', fn () => 1); @@ -34,7 +40,7 @@ public function testConsoleCommandFails() ->assertOk(); } - public function testConsoleCommandPassesWithOutput() + public function testConsoleCommandPassesWithOutput(): void { $this->registerSurveyCommand(); @@ -46,7 +52,7 @@ public function testConsoleCommandPassesWithOutput() ->assertExitCode(0); } - public function testConsoleCommandPassesWithRepeatingOutput() + public function testConsoleCommandPassesWithRepeatingOutput(): void { $this->registerSlimCommand(); @@ -61,7 +67,7 @@ public function testConsoleCommandPassesWithRepeatingOutput() ->assertExitCode(0); } - public function testConsoleCommandFailsFromUnexpectedOutput() + public function testConsoleCommandFailsFromUnexpectedOutput(): void { $this->registerSurveyCommand(); @@ -75,7 +81,7 @@ public function testConsoleCommandFailsFromUnexpectedOutput() ->assertExitCode(0); } - public function testConsoleCommandFailsFromUnexpectedOutputSubstring() + public function testConsoleCommandFailsFromUnexpectedOutputSubstring(): void { $this->registerContainsCommand(); @@ -87,7 +93,7 @@ public function testConsoleCommandFailsFromUnexpectedOutputSubstring() ->assertExitCode(0); } - public function testConsoleCommandFailsFromMissingOutput() + public function testConsoleCommandFailsFromMissingOutput(): void { $this->registerSurveyCommand(); @@ -103,7 +109,7 @@ public function testConsoleCommandFailsFromMissingOutput() }); } - public function testConsoleCommandFailsFromExitCodeMismatch() + public function testConsoleCommandFailsFromExitCodeMismatch(): void { $this->registerSurveyCommand(); @@ -116,7 +122,7 @@ public function testConsoleCommandFailsFromExitCodeMismatch() ->assertExitCode(1); } - public function testConsoleCommandFailsFromUnOrderedOutput() + public function testConsoleCommandFailsFromUnOrderedOutput(): void { $this->registerSlimCommand(); @@ -134,7 +140,7 @@ public function testConsoleCommandFailsFromUnOrderedOutput() }); } - public function testConsoleCommandPassesIfTheOutputContains() + public function testConsoleCommandPassesIfTheOutputContains(): void { $this->registerContainsCommand(); @@ -143,7 +149,7 @@ public function testConsoleCommandPassesIfTheOutputContains() ->assertExitCode(0); } - public function testConsoleCommandPassesIfOutputsSomething() + public function testConsoleCommandPassesIfOutputsSomething(): void { $this->registerContainsCommand(); @@ -152,7 +158,7 @@ public function testConsoleCommandPassesIfOutputsSomething() ->assertExitCode(0); } - public function testConsoleCommandPassesIfoutputsIsSomethingAndIsTheExpectedOutput() + public function testConsoleCommandPassesIfoutputsIsSomethingAndIsTheExpectedOutput(): void { $this->registerContainsCommand(); @@ -162,7 +168,7 @@ public function testConsoleCommandPassesIfoutputsIsSomethingAndIsTheExpectedOutp ->assertExitCode(0); } - public function testConsoleCommandFailIfDoesntOutputSomething() + public function testConsoleCommandFailIfDoesntOutputSomething(): void { Artisan::command('exit', fn () => 0); @@ -172,10 +178,10 @@ public function testConsoleCommandFailIfDoesntOutputSomething() ->expectsOutput() ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailIfDoesntOutputSomethingAndIsNotTheExpectedOutput() + public function testConsoleCommandFailIfDoesntOutputSomethingAndIsNotTheExpectedOutput(): void { Artisan::command('exit', fn () => 0); @@ -189,7 +195,7 @@ public function testConsoleCommandFailIfDoesntOutputSomethingAndIsNotTheExpected }); } - public function testConsoleCommandPassesIfDoesNotOutputAnything() + public function testConsoleCommandPassesIfDoesNotOutputAnything(): void { Artisan::command('exit', fn () => 0); @@ -198,7 +204,7 @@ public function testConsoleCommandPassesIfDoesNotOutputAnything() ->assertExitCode(0); } - public function testConsoleCommandPassesIfDoesNotOutputAnythingAndIsNotTheExpectedOutput() + public function testConsoleCommandPassesIfDoesNotOutputAnythingAndIsNotTheExpectedOutput(): void { Artisan::command('exit', fn () => 0); @@ -208,7 +214,7 @@ public function testConsoleCommandPassesIfDoesNotOutputAnythingAndIsNotTheExpect ->assertExitCode(0); } - public function testConsoleCommandPassesIfExpectsOutputAndThereIsInteractions() + public function testConsoleCommandPassesIfExpectsOutputAndThereIsInteractions(): void { $this->registerInteractionsCommand(); @@ -220,7 +226,7 @@ public function testConsoleCommandPassesIfExpectsOutputAndThereIsInteractions() ->assertExitCode(0); } - public function testConsoleCommandFailsIfDoesntExpectOutputButThereIsInteractions() + public function testConsoleCommandFailsIfDoesntExpectOutputButThereIsInteractions(): void { $this->registerInteractionsCommand(); @@ -233,10 +239,10 @@ public function testConsoleCommandFailsIfDoesntExpectOutputButThereIsInteraction ->expectsConfirmation('Do you want to continue?', 'no') ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailsIfDoesntExpectOutputButOutputsSomething() + public function testConsoleCommandFailsIfDoesntExpectOutputButOutputsSomething(): void { $this->registerContainsCommand(); @@ -246,10 +252,10 @@ public function testConsoleCommandFailsIfDoesntExpectOutputButOutputsSomething() ->doesntExpectOutput() ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailsIfDoesntExpectOutputSomethingAndIsNotExpectOutput() + public function testConsoleCommandFailsIfDoesntExpectOutputSomethingAndIsNotExpectOutput(): void { $this->registerContainsCommand(); @@ -260,10 +266,10 @@ public function testConsoleCommandFailsIfDoesntExpectOutputSomethingAndIsNotExpe ->doesntExpectOutput('My name is Albert Chen') ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailsIfTheOutputDoesNotContain() + public function testConsoleCommandFailsIfTheOutputDoesNotContain(): void { $this->registerContainsCommand(); @@ -277,7 +283,7 @@ public function testConsoleCommandFailsIfTheOutputDoesNotContain() }); } - public function testPendingCommandCanBeRapped() + public function testPendingCommandCanBeRapped(): void { Artisan::command('new-england', function () { $this->line('The region of New England consists of the following states:'); @@ -324,6 +330,137 @@ public function testPromptValidationExceptionProducesFailureWithoutErrorOutput() ->assertFailed(); } + public function testForbiddenOutputNamedStringZeroIsReported(): void + { + Artisan::command('zero-output', function () { + $this->line('0'); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Output "0" was printed.'); + + $this->artisan('zero-output')->doesntExpectOutput('0')->run(); + } + + public function testForbiddenOutputSubstringNamedStringZeroIsReported(): void + { + Artisan::command('zero-substring', function () { + $this->line('value 0'); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Output "0" was printed.'); + + $this->artisan('zero-substring')->doesntExpectOutputToContain('0')->run(); + } + + public function testCommandFailureDoesNotLeakExpectationsOrOutputBinding(): void + { + Artisan::command('throwing-command', function () { + throw new RuntimeException('command failed'); + }); + Artisan::command('clean-command', function () { + $this->line('clean output'); + }); + + try { + $this->artisan('throwing-command')->doesntExpectOutput('clean output')->run(); + $this->fail('The command did not fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('command failed', $exception->getMessage()); + } + + $this->assertConsoleExpectationsFlushed(); + $this->artisan('clean-command')->expectsOutput('clean output')->assertSuccessful(); + } + + public function testExitAssertionFailureDoesNotLeakExpectationsOrOutputBinding(): void + { + Artisan::command('failing-exit', fn () => Command::FAILURE); + Artisan::command('successful-exit', fn () => Command::SUCCESS); + + try { + $this->artisan('failing-exit')->doesntExpectOutput('never printed')->assertSuccessful()->run(); + $this->fail('The exit assertion did not fail.'); + } catch (AssertionFailedError $exception) { + $this->assertStringContainsString('Expected status code 0 but received 1.', $exception->getMessage()); + } + + $this->assertConsoleExpectationsFlushed(); + $this->artisan('successful-exit')->assertSuccessful(); + } + + public function testVerificationFailureDoesNotLeakExpectationsOrOutputBinding(): void + { + Artisan::command('missing-output', fn () => Command::SUCCESS); + Artisan::command('verified-output', function () { + $this->line('verified'); + }); + + try { + $this->artisan('missing-output')->expectsOutputToContain('missing')->run(); + $this->fail('The output assertion did not fail.'); + } catch (AssertionFailedError $exception) { + $this->assertStringContainsString('Output does not contain "missing".', $exception->getMessage()); + } + + $this->assertConsoleExpectationsFlushed(); + $this->artisan('verified-output')->expectsOutput('verified')->assertSuccessful(); + } + + public function testNoOutputExpectationDoesNotDisableMatchersOnTheNextCommand(): void + { + Artisan::command('silent-command', fn () => Command::SUCCESS); + Artisan::command('output-command', function () { + $this->line('expected output'); + }); + + $this->artisan('silent-command')->doesntExpectOutput()->assertSuccessful(); + $this->artisan('output-command')->expectsOutput('expected output')->assertSuccessful(); + } + + public function testOutputExpectationDoesNotRequireOutputFromTheNextCommand(): void + { + Artisan::command('output-command', function () { + $this->line('expected output'); + }); + Artisan::command('silent-command', fn () => Command::SUCCESS); + + $this->artisan('output-command')->expectsOutput()->assertSuccessful(); + $this->artisan('silent-command')->assertSuccessful(); + } + + public function testDdCapturesOutputAndExecutesTheCommandOnce(): void + { + $directory = ParallelTesting::tempDir('PendingCommandDdFixture'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($directory); + $filesystem->makeDirectory($directory); + $counter = $directory . '/executions.txt'; + $process = new Process( + command: [PHP_BINARY, 'tests/Console/Fixtures/PendingCommandDdFixture.php'], + cwd: dirname(__DIR__, 2), + env: [ + 'PENDING_COMMAND_DD_COUNTER' => $counter, + 'TESTBENCH_BASE_PATH' => BASE_PATH, + ], + timeout: 30, + ); + + try { + $process->run(); + + $failure = $process->getErrorOutput(); + + $this->assertSame(1, $process->getExitCode(), $failure); + $this->assertStringContainsString('fixture output', $process->getOutput(), $failure); + $this->assertStringContainsString('"exitCode" => 7', $process->getOutput(), $failure); + $this->assertSame('1', file_get_contents($counter), $failure); + } finally { + $filesystem->deleteDirectory($directory); + } + } + protected function registerSurveyCommand(): void { Artisan::command('survey', function () { @@ -373,6 +510,30 @@ protected function registerSlimCommand(): void }); } + /** + * Assert that the console expectations have been flushed. + */ + protected function assertConsoleExpectationsFlushed(): void + { + $this->assertNull($this->expectsOutput); + $this->assertSame([], $this->expectedOutput); + $this->assertSame([], $this->expectedOutputSubstrings); + $this->assertSame([], $this->unexpectedOutput); + $this->assertSame([], $this->unexpectedOutputSubstrings); + $this->assertSame([], $this->expectedQuestions); + $this->assertSame([], $this->expectedChoices); + $this->assertFalse($this->app->bound(OutputStyle::class)); + } + + /** + * Verify the PendingCommand mock expectations immediately, so an unmet + * expectation throws here and is caught by the test's expectException(). + */ + protected function verifyMockeryExpectationsNow(): void + { + m::close(); + } + protected function ignoringMockOnceExceptions(callable $callback): void { try { diff --git a/tests/Console/Fixtures/PendingCommandDdFixture.php b/tests/Console/Fixtures/PendingCommandDdFixture.php new file mode 100644 index 000000000..9debb19a6 --- /dev/null +++ b/tests/Console/Fixtures/PendingCommandDdFixture.php @@ -0,0 +1,43 @@ +make(KernelContract::class)->bootstrap(); + +Artisan::command('pending-command-dd-fixture', function () use ($counter) { + $executions = is_file($counter) ? (int) file_get_contents($counter) : 0; + file_put_contents($counter, (string) ($executions + 1)); + + $this->line('fixture output'); + + return 7; +}); + +$test = new class('fixture') extends TestCase { + /** + * Provide a concrete test method for the fixture test case. + */ + public function fixture(): void + { + } +}; + +(new PendingCommand($test, $app, 'pending-command-dd-fixture', []))->dd(); diff --git a/tests/Horizon/PackageMetadataTest.php b/tests/Horizon/PackageMetadataTest.php index 7a3e08813..28cd20f2c 100644 --- a/tests/Horizon/PackageMetadataTest.php +++ b/tests/Horizon/PackageMetadataTest.php @@ -24,7 +24,6 @@ public function testDirectRuntimeDependenciesAreDeclared(): void ); foreach ([ - 'ext-json', 'ext-mbstring', 'ext-pcntl', 'ext-posix', diff --git a/tests/Mail/MailMailableAssertionsTest.php b/tests/Mail/MailMailableAssertionsTest.php index 819cf804f..098aab394 100644 --- a/tests/Mail/MailMailableAssertionsTest.php +++ b/tests/Mail/MailMailableAssertionsTest.php @@ -224,6 +224,7 @@ public function testMailableAssertSeeInOrderInHtmlWithApostropheFailsWhenAbsentI $mailable = new MailableAssertionsStub; $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('contains "First Item" in specified order'); $mailable->assertSeeInOrderInHtml([ 'It\'s a wonderful day', @@ -231,6 +232,26 @@ public function testMailableAssertSeeInOrderInHtmlWithApostropheFailsWhenAbsentI 'Sixth Item', ]); } + + public function testMailableOrderedTextAssertionsDoNotSkipStringZero(): void + { + $mailable = new MailableAssertionsStub; + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('contains "0" in specified order'); + + $mailable->assertSeeInOrderInText(['Sixth Item', '0']); + } + + public function testMailableOrderedHtmlAssertionsDoNotSkipStringZero(): void + { + $mailable = new MailableAssertionsStub; + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('contains "0" in specified order'); + + $mailable->assertSeeInOrderInHtml(['Sixth Item', '0']); + } } class MailableAssertionsBladeEscapedStub extends Mailable diff --git a/tests/Passkeys/PackageMetadataTest.php b/tests/Passkeys/PackageMetadataTest.php index 5a7ccce36..3779ba045 100644 --- a/tests/Passkeys/PackageMetadataTest.php +++ b/tests/Passkeys/PackageMetadataTest.php @@ -31,8 +31,6 @@ public function testDirectDependenciesMatchTheMonorepoConstraints(): void foreach ([ 'php', - 'ext-hash', - 'ext-json', 'hypervel/auth', 'hypervel/collections', 'hypervel/config', @@ -64,8 +62,6 @@ public function testDirectDependenciesMatchTheMonorepoConstraints(): void } foreach ([ - 'ext-hash', - 'ext-json', 'nesbot/carbon', 'paragonie/constant_time_encoding', 'symfony/console', diff --git a/tests/Routing/PackageMetadataTest.php b/tests/Routing/PackageMetadataTest.php index 325724a37..d1de4db8e 100644 --- a/tests/Routing/PackageMetadataTest.php +++ b/tests/Routing/PackageMetadataTest.php @@ -25,7 +25,6 @@ public function testDirectRuntimeDependenciesAreDeclared(): void foreach ([ 'ext-filter' => '*', - 'ext-hash' => '*', 'hypervel/auth' => '^0.4', 'hypervel/prompts' => '^0.4', 'hypervel/rate-limiter' => '^0.4', diff --git a/tests/Sanctum/PackageMetadataTest.php b/tests/Sanctum/PackageMetadataTest.php index 466775c8b..ab6e921c4 100644 --- a/tests/Sanctum/PackageMetadataTest.php +++ b/tests/Sanctum/PackageMetadataTest.php @@ -33,7 +33,6 @@ public function testDependenciesAndProviderAreDeclared(): void foreach ([ 'ext-ctype', 'ext-filter', - 'ext-json', 'hypervel/cookie', 'hypervel/foundation', 'hypervel/session', diff --git a/tests/Telescope/PackageMetadataTest.php b/tests/Telescope/PackageMetadataTest.php index df97af53f..ded0e3520 100644 --- a/tests/Telescope/PackageMetadataTest.php +++ b/tests/Telescope/PackageMetadataTest.php @@ -56,7 +56,6 @@ public function testDirectDependenciesAndProviderAreDeclared(): void 'hypervel/view', ]; $externalDependencies = [ - 'ext-json', 'ext-mbstring', 'ext-pdo', 'guzzlehttp/guzzle', diff --git a/tests/Testing/AssertTest.php b/tests/Testing/AssertTest.php index df11ec87d..f147d0afd 100644 --- a/tests/Testing/AssertTest.php +++ b/tests/Testing/AssertTest.php @@ -11,7 +11,7 @@ class AssertTest extends TestCase { - public function testArraySubset() + public function testArraySubset(): void { Assert::assertArraySubset([ 'string' => 'string', @@ -23,7 +23,7 @@ public function testArraySubset() ]); } - public function testArraySubsetMayFail() + public function testArraySubsetMayFail(): void { $this->expectException(ExpectationFailedException::class); @@ -38,7 +38,7 @@ public function testArraySubsetMayFail() ]); } - public function testArraySubsetWithStrict() + public function testArraySubsetWithStrict(): void { Assert::assertArraySubset([ 'string' => 'string', @@ -50,7 +50,7 @@ public function testArraySubsetWithStrict() ], true); } - public function testArraySubsetWithStrictMayFail() + public function testArraySubsetWithStrictMayFail(): void { $this->expectException(ExpectationFailedException::class); diff --git a/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php b/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php index dc699c44c..01998f0f5 100644 --- a/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php +++ b/tests/Testing/Concerns/InteractsWithDeprecationHandlingTest.php @@ -33,7 +33,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testWithDeprecationHandling() + public function testWithDeprecationHandling(): void { $this->withDeprecationHandling(); @@ -42,7 +42,7 @@ public function testWithDeprecationHandling() $this->assertTrue($this->deprecationsFound); } - public function testWithoutDeprecationHandling() + public function testWithoutDeprecationHandling(): void { $this->withoutDeprecationHandling(); diff --git a/tests/Testing/Concerns/TestCachesTest.php b/tests/Testing/Concerns/TestCachesTest.php index b7d8a20df..b46cf33ea 100644 --- a/tests/Testing/Concerns/TestCachesTest.php +++ b/tests/Testing/Concerns/TestCachesTest.php @@ -58,7 +58,7 @@ protected function tearDown(): void } #[DataProvider('cachePrefixes')] - public function testCachePrefixAppendsToken(string $prefix, string $token, string $expected) + public function testCachePrefixAppendsToken(string $prefix, string $token, string $expected): void { Container::getInstance()['config']->set('cache.prefix', $prefix); Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => $token); @@ -72,7 +72,7 @@ public static function cachePrefixes(): Generator yield 'empty prefix' => ['', '3', 'test_3_']; } - public function testCachePrefixReflectsCurrentToken() + public function testCachePrefixReflectsCurrentToken(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); @@ -83,7 +83,7 @@ public function testCachePrefixReflectsCurrentToken() $this->assertSame('myapp_cache_test_2_', $this->getParallelSafeCachePrefix()); } - public function testCachePrefixDoesNotReuseCustomPrefixFromPreviousCall() + public function testCachePrefixDoesNotReuseCustomPrefixFromPreviousCall(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); @@ -96,7 +96,7 @@ public function testCachePrefixDoesNotReuseCustomPrefixFromPreviousCall() $this->assertSame('myapp_cache_test_1_', $this->getParallelSafeCachePrefix()); } - public function testCachePrefixDoesNotDoubleAppendToken() + public function testCachePrefixDoesNotDoubleAppendToken(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); Container::getInstance()['config']->set('cache.prefix', 'myapp_cache_test_1_'); @@ -104,14 +104,14 @@ public function testCachePrefixDoesNotDoubleAppendToken() $this->assertSame('myapp_cache_test_1_', $this->getParallelSafeCachePrefix()); } - public function testSwitchToCachePrefixUpdatesConfig() + public function testSwitchToCachePrefixUpdatesConfig(): void { $this->switchToCachePrefix('new_prefix_'); $this->assertSame('new_prefix_', Container::getInstance()['config']->get('cache.prefix')); } - public function testBootTestCacheRegistersSetUpTestCaseCallback() + public function testBootTestCacheRegistersSetUpTestCaseCallback(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '7'); @@ -126,7 +126,7 @@ public function testBootTestCacheRegistersSetUpTestCaseCallback() $this->assertCount(1, $setUpCallbacks); } - public function testBootTestCacheSkipsIsolationIfOptedOut() + public function testBootTestCacheSkipsIsolationIfOptedOut(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '7'); @@ -134,16 +134,25 @@ public function testBootTestCacheSkipsIsolationIfOptedOut() (new ReflectionMethod($instance, 'bootTestCache'))->invoke($instance); - $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] = 1; + $hadValue = array_key_exists('HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE', $_SERVER); + $original = $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] ?? null; - Container::getInstance()->make(ParallelTesting::class)->callSetUpTestCaseCallbacks(new class {}); + try { + $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] = '1'; - $this->assertSame('myapp_cache_', Container::getInstance()['config']->get('cache.prefix')); + Container::getInstance()->make(ParallelTesting::class)->callSetUpTestCaseCallbacks(new class {}); - unset($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE']); + $this->assertSame('myapp_cache_', Container::getInstance()['config']->get('cache.prefix')); + } finally { + if ($hadValue) { + $_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE'] = $original; + } else { + unset($_SERVER['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE']); + } + } } - public function testSwitchToCachePrefixDoesNotRemoveResolvedDrivers() + public function testSwitchToCachePrefixDoesNotRemoveResolvedDrivers(): void { $container = Container::getInstance(); diff --git a/tests/Testing/Concerns/TestDatabasesTest.php b/tests/Testing/Concerns/TestDatabasesTest.php index 6f4117b29..4672726a0 100644 --- a/tests/Testing/Concerns/TestDatabasesTest.php +++ b/tests/Testing/Concerns/TestDatabasesTest.php @@ -56,7 +56,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testSwitchToDatabaseWithoutUrl() + public function testSwitchToDatabaseWithoutUrl(): void { $container = Container::getInstance(); @@ -78,7 +78,7 @@ public function testSwitchToDatabaseWithoutUrl() } #[DataProvider('databaseUrls')] - public function testSwitchToDatabaseWithUrl(string $testDatabase, string $url, string $testUrl) + public function testSwitchToDatabaseWithUrl(string $testDatabase, string $url, string $testUrl): void { $container = Container::getInstance(); @@ -120,7 +120,7 @@ public static function databaseUrls(): array ]; } - public function testDatabaseNameDoesNotReuseCustomNameFromPreviousCall() + public function testDatabaseNameDoesNotReuseCustomNameFromPreviousCall(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); @@ -128,7 +128,7 @@ public function testDatabaseNameDoesNotReuseCustomNameFromPreviousCall() $this->assertSame('my_database_test_1', $this->testDatabase('my_database')); } - public function testDatabaseNameDoesNotDoubleAppendToken() + public function testDatabaseNameDoesNotDoubleAppendToken(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); diff --git a/tests/Testing/Concerns/TestViewsTest.php b/tests/Testing/Concerns/TestViewsTest.php index f8522c5c2..30ba1219c 100644 --- a/tests/Testing/Concerns/TestViewsTest.php +++ b/tests/Testing/Concerns/TestViewsTest.php @@ -57,14 +57,14 @@ protected function tearDown(): void parent::tearDown(); } - public function testCompiledViewPathAppendsToken() + public function testCompiledViewPathAppendsToken(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '5'); $this->assertSame('/path/to/compiled/views/test_5', $this->getCompiledViewPath()); } - public function testCompiledViewPathTrimsTrailingSlash() + public function testCompiledViewPathTrimsTrailingSlash(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '3'); @@ -73,7 +73,7 @@ public function testCompiledViewPathTrimsTrailingSlash() $this->assertSame('/path/to/compiled/views/test_3', $this->getCompiledViewPath()); } - public function testCompiledViewPathWithDifferentToken() + public function testCompiledViewPathWithDifferentToken(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '42'); @@ -82,7 +82,7 @@ public function testCompiledViewPathWithDifferentToken() $this->assertSame('/var/www/storage/views/test_42', $this->getCompiledViewPath()); } - public function testCompiledViewPathDoesNotReuseCustomPathFromPreviousCall() + public function testCompiledViewPathDoesNotReuseCustomPathFromPreviousCall(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); @@ -95,7 +95,7 @@ public function testCompiledViewPathDoesNotReuseCustomPathFromPreviousCall() $this->assertSame('/path/to/compiled/views/test_1', $this->getCompiledViewPath()); } - public function testCompiledViewPathDoesNotDoubleAppendToken() + public function testCompiledViewPathDoesNotDoubleAppendToken(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '1'); Container::getInstance()['config']->set('view.compiled', '/path/to/compiled/views/test_1'); @@ -103,21 +103,21 @@ public function testCompiledViewPathDoesNotDoubleAppendToken() $this->assertSame('/path/to/compiled/views/test_1', $this->getCompiledViewPath()); } - public function testCompiledViewPathReturnsNullWhenEmpty() + public function testCompiledViewPathReturnsNullWhenEmpty(): void { Container::getInstance()['config']->set('view.compiled', ''); $this->assertNull($this->getCompiledViewPath()); } - public function testSwitchToCompiledViewPathUpdatesConfig() + public function testSwitchToCompiledViewPathUpdatesConfig(): void { $this->switchToCompiledViewPath('/new/compiled/path'); $this->assertSame('/new/compiled/path', Container::getInstance()['config']->get('view.compiled')); } - public function testSwitchToCompiledViewPathUpdatesCompilerCachePath() + public function testSwitchToCompiledViewPathUpdatesCompilerCachePath(): void { $container = Container::getInstance(); $compiler = new BladeCompiler(m::mock(Filesystem::class), '/original/path'); @@ -130,7 +130,7 @@ public function testSwitchToCompiledViewPathUpdatesCompilerCachePath() $this->assertSame('/new/compiled/path', (new ReflectionProperty($compiler, 'cachePath'))->getValue($compiler)); } - public function testTearDownProcessDeletesCompiledViewDirectory() + public function testTearDownProcessDeletesCompiledViewDirectory(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '7'); diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php index d74549e4e..02f8b36a6 100644 --- a/tests/Testing/Console/TestCommandTest.php +++ b/tests/Testing/Console/TestCommandTest.php @@ -8,18 +8,94 @@ use Hypervel\Support\Env; use Hypervel\Testbench\TestCase; use Hypervel\Testing\Console\TestCommand; +use Hypervel\Testing\Coverage; use Hypervel\Testing\ParallelRunner; +use Hypervel\Testing\ParallelTesting; use Hypervel\Testing\Profile\ProfileExtension; use Override; use PHPUnit\Framework\Attributes\Test; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use RuntimeException; use Symfony\Component\Console\Tester\CommandTester; +use Throwable; use function Hypervel\Testbench\package_path; class TestCommandTest extends TestCase { + /** @var array */ + private array $originalConfigurationFiles = []; + + /** @var array{bool, null|list} */ + private array $originalArguments; + + protected function setUp(): void + { + $this->originalArguments = [ + array_key_exists('argv', $_SERVER), + $_SERVER['argv'] ?? null, + ]; + + parent::setUp(); + + foreach (['phpunit.xml', 'custom-phpunit.xml'] as $file) { + $path = $this->app->basePath($file); + $this->originalConfigurationFiles[$path] = [ + is_file($path), + is_file($path) ? (string) file_get_contents($path) : null, + ]; + } + } + + protected function tearDown(): void + { + $exception = null; + + try { + $profileFiles = glob($this->app->basePath('.hypervel-phpunit-profile-*.xml')) ?: []; + } catch (Throwable $throwable) { + $exception = $throwable; + $profileFiles = []; + } + + foreach ($profileFiles as $path) { + try { + unlink($path); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + foreach ($this->originalConfigurationFiles as $path => [$existed, $contents]) { + try { + if ($existed) { + file_put_contents($path, $contents); + } elseif (is_file($path)) { + unlink($path); + } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($this->originalArguments[0]) { + $_SERVER['argv'] = $this->originalArguments[1]; + } else { + unset($_SERVER['argv']); + } + + try { + parent::tearDown(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } + } + #[Test] public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): void { @@ -54,7 +130,6 @@ public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): v public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void { $basePath = $this->createProfileProject(); - $originalArguments = $_SERVER['argv'] ?? []; $_SERVER['argv'] = ['artisan', 'test', '--profile']; $command = new TestCommandHarness(['profile' => true, 'without-tty' => true], $basePath); @@ -69,7 +144,6 @@ public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void $this->assertStringContainsString('Top 10 slowest tests', $display); $this->assertStringContainsString('ProfileExampleTest', $display); } finally { - $_SERVER['argv'] = $originalArguments; $this->removeDirectory($basePath); } } @@ -78,7 +152,6 @@ public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void public function itShowsNativePhpunitOutputForSequentialTests(): void { $basePath = $this->createProfileProject(); - $originalArguments = $_SERVER['argv'] ?? []; $_SERVER['argv'] = ['artisan', 'test']; $command = new TestCommandHarness(['without-tty' => true], $basePath); @@ -93,7 +166,6 @@ public function itShowsNativePhpunitOutputForSequentialTests(): void $this->assertStringContainsString('OK (1 test', $display); $this->assertStringContainsString('1 assertion', $display); } finally { - $_SERVER['argv'] = $originalArguments; $this->removeDirectory($basePath); } } @@ -192,15 +264,15 @@ public function itUsesTheRequestedEnvironmentForChildProcesses(): void #[Test] public function itClearsConfiguredEnvironmentVariablesFromEveryEnvironmentStore(): void { - $basePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-test-command-env-' - . getmypid() . '-' . bin2hex(random_bytes(6)); + $basePath = ParallelTesting::tempDir('TestCommandEnvironment'); $environmentFile = '.env.command-clear'; $keys = [ 'HYPERVEL_TEST_COMMAND_CLEAR_ONE', 'HYPERVEL_TEST_COMMAND_CLEAR_TWO', ]; - mkdir($basePath, 0777, true); + $this->removeDirectory($basePath); + mkdir($basePath, 0700, true); file_put_contents($basePath . DIRECTORY_SEPARATOR . $environmentFile, implode("\n", [ 'HYPERVEL_TEST_COMMAND_CLEAR_ONE=one', 'HYPERVEL_TEST_COMMAND_CLEAR_TWO=two', @@ -300,6 +372,175 @@ public function itFiltersSpaceSeparatedCommandOptionValuesFromForwardedArguments $this->assertNotContains('80', $arguments); } + #[Test] + public function itRejectsAnUnpublishedTemporaryConfigurationFile(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('Permission checks are unreliable when running as root.'); + } + + // A crashed run can leave this fixture read-only, so it never reuses a path. + $basePath = ParallelTesting::tempDir( + 'TestCommandUnpublishedConfiguration-' . bin2hex(random_bytes(6)), + ); + + mkdir($basePath, 0700, true); + try { + file_put_contents($basePath . DIRECTORY_SEPARATOR . 'phpunit.xml', <<<'XML' + + +XML); + chmod($basePath, 0555); + + $command = new TestCommandHarness(['profile' => true], $basePath); + $command->setHypervel($this->app); + + $command->phpUnitConfigurationFilePublic(); + $this->fail('The temporary configuration write failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertStringStartsWith( + "Unable to write temporary PHPUnit configuration [{$basePath}/.hypervel-phpunit-profile-", + $exception->getMessage(), + ); + } finally { + chmod($basePath, 0700); + $this->removeDirectory($basePath); + } + } + + #[Test] + public function itCleansEveryOwnedResourceWhenArgumentConstructionFails(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->phpunitArgumentsException = new RuntimeException('arguments failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The argument construction exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('arguments failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame(['temporary configuration', 'profile directory'], $command->cleanupOrder); + } + + #[Test] + public function itCleansEveryOwnedResourceAfterANonInterruptSignal(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->processCode = 'posix_kill(getmypid(), SIGTERM);'; + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The process signal exception was not thrown.'); + } catch (Throwable $throwable) { + $this->assertSame('The process has been signaled with signal "15".', $throwable->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame(['temporary configuration', 'profile directory'], $command->cleanupOrder); + } + + #[Test] + public function itCleansEveryOwnedResourceWhenProfileReportingFails(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->profileReportException = new RuntimeException('report failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The profile report exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('report failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame(['temporary configuration', 'profile directory'], $command->cleanupOrder); + } + + #[Test] + public function itPreservesTheOperationFailureWhileExhaustingCleanup(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->allocateCoverageInBinary = true; + $command->profileReportException = new RuntimeException('operation failed'); + $command->temporaryConfigurationCleanupException = new RuntimeException('temporary cleanup failed'); + $command->coverageCleanupException = new RuntimeException('coverage cleanup failed'); + $command->profileCleanupException = new RuntimeException('profile cleanup failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The operation exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('operation failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame( + ['temporary configuration', 'coverage', 'profile directory'], + $command->cleanupOrder, + ); + } + + #[Test] + public function itThrowsTheFirstCleanupFailureAndStillRunsLaterCleanup(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->allocateCoverageInBinary = true; + $command->temporaryConfigurationCleanupException = new RuntimeException('temporary cleanup failed'); + $command->coverageCleanupException = new RuntimeException('coverage cleanup failed'); + $command->profileCleanupException = new RuntimeException('profile cleanup failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The cleanup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('temporary cleanup failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame( + ['temporary configuration', 'coverage', 'profile directory'], + $command->cleanupOrder, + ); + } + + /** + * Assert that every command-owned filesystem resource was removed. + */ + protected function assertCommandResourcesWereRemoved(TestCommandFailureHarness $command): void + { + $this->assertNotNull($command->createdTemporaryConfigurationFile); + $this->assertFileDoesNotExist($command->createdTemporaryConfigurationFile); + $this->assertNotNull($command->createdProfileDirectory); + $this->assertDirectoryDoesNotExist($command->createdProfileDirectory); + + if ($command->coverageReporter !== null) { + $this->assertFileDoesNotExist($command->coverageReporter->path()); + } + } + /** * Write a PHPUnit configuration file into the disposable testbench app. */ @@ -339,10 +580,10 @@ protected function firstConfigurationArgument(array $arguments): string */ protected function createProfileProject(): string { - $basePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-profile-project-' - . getmypid() . '-' . bin2hex(random_bytes(6)); + $basePath = ParallelTesting::tempDir('TestCommandProfileProject'); - mkdir($basePath . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'Feature', 0777, true); + $this->removeDirectory($basePath); + mkdir($basePath . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'Feature', 0700, true); symlink(package_path('vendor'), $basePath . DIRECTORY_SEPARATOR . 'vendor'); file_put_contents($basePath . DIRECTORY_SEPARATOR . 'phpunit.xml', <<<'XML' @@ -408,7 +649,7 @@ protected function removeDirectory(string $path): void } } -final class TestCommandHarness extends TestCommand +class TestCommandHarness extends TestCommand { /** * Create a new test command harness. @@ -535,3 +776,153 @@ public function clearEnvPublic(): void $this->clearEnv(); } } + +class TestCommandFailureHarness extends TestCommandHarness +{ + public bool $allocateCoverageInBinary = false; + + public string $processCode = 'exit(0);'; + + public ?Throwable $phpunitArgumentsException = null; + + public ?Throwable $profileReportException = null; + + public ?Throwable $temporaryConfigurationCleanupException = null; + + public ?Throwable $coverageCleanupException = null; + + public ?Throwable $profileCleanupException = null; + + public ?string $createdTemporaryConfigurationFile = null; + + public ?string $createdProfileDirectory = null; + + public ?TestCommandFailureCoverage $coverageReporter = null; + + /** @var list */ + public array $cleanupOrder = []; + + /** + * Get the PHP binary to execute. + * + * @return array + */ + #[Override] + protected function binary(): array + { + if ($this->allocateCoverageInBinary) { + $this->coverage(); + } + + return [PHP_BINARY, '-r', $this->processCode, '--']; + } + + /** + * Get the array of arguments for running PHPUnit. + * + * @param array $options + * @return array + */ + #[Override] + protected function phpunitArguments(array $options): array + { + $arguments = parent::phpunitArguments($options); + + if ($this->phpunitArgumentsException !== null) { + throw $this->phpunitArgumentsException; + } + + return $arguments; + } + + /** + * Add the profile extension to a temporary PHPUnit configuration file. + */ + #[Override] + protected function profileConfigurationFile(string $file): string + { + return $this->createdTemporaryConfigurationFile = parent::profileConfigurationFile($file); + } + + /** + * Get the profile directory. + */ + #[Override] + protected function profileDirectory(): string + { + return $this->createdProfileDirectory = parent::profileDirectory(); + } + + /** + * Get the coverage reporter. + */ + #[Override] + protected function coverage(): Coverage + { + return $this->coverage ??= $this->coverageReporter ??= new TestCommandFailureCoverage($this); + } + + /** + * Report the slowest tests. + */ + #[Override] + protected function reportProfile(): void + { + if ($this->profileReportException !== null) { + throw $this->profileReportException; + } + } + + /** + * Remove the temporary PHPUnit configuration file. + */ + #[Override] + protected function cleanupTemporaryConfigurationFile(): void + { + $this->cleanupOrder[] = 'temporary configuration'; + parent::cleanupTemporaryConfigurationFile(); + + if ($this->temporaryConfigurationCleanupException !== null) { + throw $this->temporaryConfigurationCleanupException; + } + } + + /** + * Remove profile data. + */ + #[Override] + protected function cleanupProfileDirectory(): void + { + $this->cleanupOrder[] = 'profile directory'; + parent::cleanupProfileDirectory(); + + if ($this->profileCleanupException !== null) { + throw $this->profileCleanupException; + } + } +} + +class TestCommandFailureCoverage extends Coverage +{ + /** + * Create a coverage cleanup harness. + */ + public function __construct(private readonly TestCommandFailureHarness $command) + { + parent::__construct(); + } + + /** + * Remove temporary coverage data. + */ + #[Override] + public function cleanup(): void + { + $this->command->cleanupOrder[] = 'coverage'; + parent::cleanup(); + + if ($this->command->coverageCleanupException !== null) { + throw $this->command->coverageCleanupException; + } + } +} diff --git a/tests/Testing/Fluent/AssertTest.php b/tests/Testing/Fluent/AssertTest.php index e88ce987a..256e1ea0d 100644 --- a/tests/Testing/Fluent/AssertTest.php +++ b/tests/Testing/Fluent/AssertTest.php @@ -14,7 +14,7 @@ class AssertTest extends TestCase { - public function testAssertHas() + public function testAssertHas(): void { $assert = AssertableJson::fromArray([ 'prop' => 'value', @@ -23,7 +23,7 @@ public function testAssertHas() $assert->has('prop'); } - public function testAssertHasFailsWhenPropMissing() + public function testAssertHasFailsWhenPropMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -35,7 +35,7 @@ public function testAssertHasFailsWhenPropMissing() $assert->has('prop'); } - public function testAssertHasNestedProp() + public function testAssertHasNestedProp(): void { $assert = AssertableJson::fromArray([ 'example' => [ @@ -46,7 +46,7 @@ public function testAssertHasNestedProp() $assert->has('example.nested'); } - public function testAssertHasFailsWhenNestedPropMissing() + public function testAssertHasFailsWhenNestedPropMissing(): void { $assert = AssertableJson::fromArray([ 'example' => [ @@ -60,7 +60,7 @@ public function testAssertHasFailsWhenNestedPropMissing() $assert->has('example.another'); } - public function testAssertHasCountItemsInProp() + public function testAssertHasCountItemsInProp(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -72,7 +72,7 @@ public function testAssertHasCountItemsInProp() $assert->has('bar', 2); } - public function testAssertHasCountFailsWhenAmountOfItemsDoesNotMatch() + public function testAssertHasCountFailsWhenAmountOfItemsDoesNotMatch(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -87,7 +87,7 @@ public function testAssertHasCountFailsWhenAmountOfItemsDoesNotMatch() $assert->has('bar', 1); } - public function testAssertHasCountFailsWhenPropMissing() + public function testAssertHasCountFailsWhenPropMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -102,7 +102,7 @@ public function testAssertHasCountFailsWhenPropMissing() $assert->has('baz', 1); } - public function testAssertHasFailsWhenSecondArgumentUnsupportedType() + public function testAssertHasFailsWhenSecondArgumentUnsupportedType(): void { $assert = AssertableJson::fromArray([ 'bar' => 'baz', @@ -113,7 +113,7 @@ public function testAssertHasFailsWhenSecondArgumentUnsupportedType() $assert->has('bar', 'invalid'); } - public function testAssertHasOnlyCounts() + public function testAssertHasOnlyCounts(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -124,7 +124,7 @@ public function testAssertHasOnlyCounts() $assert->has(3); } - public function testAssertHasOnlyCountFails() + public function testAssertHasOnlyCountFails(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -138,7 +138,7 @@ public function testAssertHasOnlyCountFails() $assert->has(2); } - public function testAssertHasOnlyCountFailsScoped() + public function testAssertHasOnlyCountFailsScoped(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -155,7 +155,7 @@ public function testAssertHasOnlyCountFailsScoped() }); } - public function testAssertHasWithWhereNotDoesNotFail() + public function testAssertHasWithWhereNotDoesNotFail(): void { $assert = AssertableJson::fromArray([ 'data' => [ @@ -176,7 +176,7 @@ public function testAssertHasWithWhereNotDoesNotFail() }); } - public function testAssertHasWithWhereNotFails() + public function testAssertHasWithWhereNotFails(): void { $assert = AssertableJson::fromArray([ 'data' => [ @@ -200,7 +200,7 @@ public function testAssertHasWithWhereNotFails() }); } - public function testAssertHasWithWhereNotDoesNotFailClosure() + public function testAssertHasWithWhereNotDoesNotFailClosure(): void { $assert = AssertableJson::fromArray([ 'data' => [ @@ -221,7 +221,7 @@ public function testAssertHasWithWhereNotDoesNotFailClosure() }); } - public function testAssertHasWithWhereNotFailsClosure() + public function testAssertHasWithWhereNotFailsClosure(): void { $assert = AssertableJson::fromArray([ 'data' => [ @@ -245,7 +245,7 @@ public function testAssertHasWithWhereNotFailsClosure() }); } - public function testAssertCount() + public function testAssertCount(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -256,7 +256,7 @@ public function testAssertCount() $assert->count(3); } - public function testAssertCountFails() + public function testAssertCountFails(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -270,7 +270,7 @@ public function testAssertCountFails() $assert->count(2); } - public function testAssertCountFailsScoped() + public function testAssertCountFailsScoped(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -287,7 +287,7 @@ public function testAssertCountFailsScoped() }); } - public function testAssertBetween() + public function testAssertBetween(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -298,7 +298,7 @@ public function testAssertBetween() $assert->countBetween(1, 3); } - public function testAssertBetweenFails() + public function testAssertBetweenFails(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -312,7 +312,7 @@ public function testAssertBetweenFails() $assert->countBetween(1, 2); } - public function testAssertBetweenLowestValueFails() + public function testAssertBetweenLowestValueFails(): void { $assert = AssertableJson::fromArray([ 'foo', @@ -326,7 +326,7 @@ public function testAssertBetweenLowestValueFails() $assert->countBetween(4, 3); } - public function testAssertBetweenFailsScoped() + public function testAssertBetweenFailsScoped(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -344,7 +344,7 @@ public function testAssertBetweenFailsScoped() }); } - public function testAssertMissing() + public function testAssertMissing(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -355,7 +355,7 @@ public function testAssertMissing() $assert->missing('foo.baz'); } - public function testAssertMissingFailsWhenPropExists() + public function testAssertMissingFailsWhenPropExists(): void { $assert = AssertableJson::fromArray([ 'prop' => 'value', @@ -370,7 +370,7 @@ public function testAssertMissingFailsWhenPropExists() $assert->missing('foo.bar'); } - public function testAssertMissingAll() + public function testAssertMissingAll(): void { $assert = AssertableJson::fromArray([ 'baz' => 'foo', @@ -382,7 +382,7 @@ public function testAssertMissingAll() ]); } - public function testAssertMissingAllFailsWhenAtLeastOnePropExists() + public function testAssertMissingAllFailsWhenAtLeastOnePropExists(): void { $assert = AssertableJson::fromArray([ 'baz' => 'foo', @@ -397,7 +397,7 @@ public function testAssertMissingAllFailsWhenAtLeastOnePropExists() ]); } - public function testAssertMissingAllAcceptsMultipleArgumentsInsteadOfArray() + public function testAssertMissingAllAcceptsMultipleArgumentsInsteadOfArray(): void { $assert = AssertableJson::fromArray([ 'baz' => 'foo', @@ -411,7 +411,7 @@ public function testAssertMissingAllAcceptsMultipleArgumentsInsteadOfArray() $assert->missingAll('bar', 'baz'); } - public function testAssertWhereMatchesValue() + public function testAssertWhereMatchesValue(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -420,7 +420,7 @@ public function testAssertWhereMatchesValue() $assert->where('bar', 'value'); } - public function testAssertWhereFailsWhenDoesNotMatchValue() + public function testAssertWhereFailsWhenDoesNotMatchValue(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -432,7 +432,7 @@ public function testAssertWhereFailsWhenDoesNotMatchValue() $assert->where('bar', 'invalid'); } - public function testAssertWhereFailsWhenMissing() + public function testAssertWhereFailsWhenMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -444,7 +444,7 @@ public function testAssertWhereFailsWhenMissing() $assert->where('baz', 'invalid'); } - public function testAssertWhereFailsWhenMatchingLoosely() + public function testAssertWhereFailsWhenMatchingLoosely(): void { $assert = AssertableJson::fromArray([ 'bar' => 1, @@ -456,7 +456,7 @@ public function testAssertWhereFailsWhenMatchingLoosely() $assert->where('bar', true); } - public function testAssertWhereUsingClosure() + public function testAssertWhereUsingClosure(): void { $assert = AssertableJson::fromArray([ 'bar' => 'baz', @@ -467,7 +467,7 @@ public function testAssertWhereUsingClosure() }); } - public function testAssertWhereFailsWhenDoesNotMatchValueUsingClosure() + public function testAssertWhereFailsWhenDoesNotMatchValueUsingClosure(): void { $assert = AssertableJson::fromArray([ 'bar' => 'baz', @@ -481,7 +481,7 @@ public function testAssertWhereFailsWhenDoesNotMatchValueUsingClosure() }); } - public function testAssertWhereClosureArrayValuesAreAutomaticallyCastedToCollections() + public function testAssertWhereClosureArrayValuesAreAutomaticallyCastedToCollections(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -497,7 +497,7 @@ public function testAssertWhereClosureArrayValuesAreAutomaticallyCastedToCollect }); } - public function testAssertWhereMatchesValueUsingArrayable() + public function testAssertWhereMatchesValueUsingArrayable(): void { $stub = ArrayableStubObject::make(['foo' => 'bar']); @@ -508,7 +508,7 @@ public function testAssertWhereMatchesValueUsingArrayable() $assert->where('bar', $stub); } - public function testAssertWhereMatchesValueUsingArrayableWhenSortedDifferently() + public function testAssertWhereMatchesValueUsingArrayableWhenSortedDifferently(): void { $assert = AssertableJson::fromArray([ 'data' => [ @@ -529,7 +529,7 @@ public function testAssertWhereMatchesValueUsingArrayableWhenSortedDifferently() ]); } - public function testAssertWhereFailsWhenDoesNotMatchValueUsingArrayable() + public function testAssertWhereFailsWhenDoesNotMatchValueUsingArrayable(): void { $assert = AssertableJson::fromArray([ 'bar' => ['id' => 1, 'name' => 'Example'], @@ -555,7 +555,7 @@ public function testAssertWhereFailsWhenDoesNotMatchValueUsingArrayable() ]); } - public function testAssertWhereUsingBackedEnum() + public function testAssertWhereUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'bar' => BackedEnum::Test->value, @@ -570,7 +570,7 @@ public function testAssertWhereUsingBackedEnum() $assert->where('bar', BackedEnum::TestEmpty); } - public function testAssertWhereFailsUsingBackedEnum() + public function testAssertWhereFailsUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'bar' => BackedEnum::Test->value, @@ -582,7 +582,7 @@ public function testAssertWhereFailsUsingBackedEnum() $assert->where('bar', BackedEnum::TestEmpty); } - public function testAssertWhereNullMatchesValue() + public function testAssertWhereNullMatchesValue(): void { $assert = AssertableJson::fromArray([ 'bar' => null, @@ -591,7 +591,7 @@ public function testAssertWhereNullMatchesValue() $assert->whereNull('bar'); } - public function testAssertWhereNullFailsWhenNotNull() + public function testAssertWhereNullFailsWhenNotNull(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -603,7 +603,7 @@ public function testAssertWhereNullFailsWhenNotNull() $assert->whereNull('bar'); } - public function testAssertWhereNullFailsWhenMissing() + public function testAssertWhereNullFailsWhenMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -615,7 +615,7 @@ public function testAssertWhereNullFailsWhenMissing() $assert->whereNull('baz'); } - public function testAssertWhereNotNullMatchesValue() + public function testAssertWhereNotNullMatchesValue(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -624,7 +624,7 @@ public function testAssertWhereNotNullMatchesValue() $assert->whereNotNull('bar'); } - public function testAssertWhereNotNullFailsWhenNull() + public function testAssertWhereNotNullFailsWhenNull(): void { $assert = AssertableJson::fromArray([ 'bar' => null, @@ -636,7 +636,7 @@ public function testAssertWhereNotNullFailsWhenNull() $assert->whereNotNull('bar'); } - public function testAssertWhereNotNullFailsWhenMissing() + public function testAssertWhereNotNullFailsWhenMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -648,7 +648,7 @@ public function testAssertWhereNotNullFailsWhenMissing() $assert->whereNotNull('baz'); } - public function testAssertWhereContainsFailsWithEmptyValue() + public function testAssertWhereContainsFailsWithEmptyValue(): void { $assert = AssertableJson::fromArray([]); @@ -658,7 +658,7 @@ public function testAssertWhereContainsFailsWithEmptyValue() $assert->whereContains('foo', ['1']); } - public function testAssertWhereContainsFailsWithMissingValue() + public function testAssertWhereContainsFailsWithMissingValue(): void { $assert = AssertableJson::fromArray([ 'foo' => ['bar', 'baz'], @@ -670,7 +670,7 @@ public function testAssertWhereContainsFailsWithMissingValue() $assert->whereContains('foo', ['bar', 'baz', 'invalid']); } - public function testAssertWhereContainsFailsWithMissingNestedValue() + public function testAssertWhereContainsFailsWithMissingNestedValue(): void { $assert = AssertableJson::fromArray([ ['id' => 1], @@ -685,7 +685,7 @@ public function testAssertWhereContainsFailsWithMissingNestedValue() $assert->whereContains('id', [1, 2, 3, 4, 5]); } - public function testAssertWhereContainsFailsWhenDoesNotMatchType() + public function testAssertWhereContainsFailsWhenDoesNotMatchType(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -697,7 +697,7 @@ public function testAssertWhereContainsFailsWhenDoesNotMatchType() $assert->whereContains('foo', ['1']); } - public function testAssertWhereContainsFailsWhenDoesNotSatisfyClosure() + public function testAssertWhereContainsFailsWhenDoesNotSatisfyClosure(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -711,7 +711,7 @@ public function testAssertWhereContainsFailsWhenDoesNotSatisfyClosure() }]); } - public function testAssertWhereContainsFailsWhenHavingExpectedValueButDoesNotSatisfyClosure() + public function testAssertWhereContainsFailsWhenHavingExpectedValueButDoesNotSatisfyClosure(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -725,7 +725,7 @@ public function testAssertWhereContainsFailsWhenHavingExpectedValueButDoesNotSat }]); } - public function testAssertWhereContainsFailsWhenSatisfiesClosureButDoesNotHaveExpectedValue() + public function testAssertWhereContainsFailsWhenSatisfiesClosureButDoesNotHaveExpectedValue(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -739,7 +739,7 @@ public function testAssertWhereContainsFailsWhenSatisfiesClosureButDoesNotHaveEx }]); } - public function testAssertWhereContainsWithNestedValue() + public function testAssertWhereContainsWithNestedValue(): void { $assert = AssertableJson::fromArray([ ['id' => 1], @@ -753,7 +753,7 @@ public function testAssertWhereContainsWithNestedValue() $assert->whereContains('id', [4, 3, 2, 1]); } - public function testAssertWhereContainsWithMatchingType() + public function testAssertWhereContainsWithMatchingType(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -763,7 +763,7 @@ public function testAssertWhereContainsWithMatchingType() $assert->whereContains('foo', [1]); } - public function testAssertWhereContainsWithNullValue() + public function testAssertWhereContainsWithNullValue(): void { $assert = AssertableJson::fromArray([ 'foo' => null, @@ -773,7 +773,7 @@ public function testAssertWhereContainsWithNullValue() $assert->whereContains('foo', [null]); } - public function testAssertWhereContainsWithOutOfOrderMatchingType() + public function testAssertWhereContainsWithOutOfOrderMatchingType(): void { $assert = AssertableJson::fromArray([ 'foo' => [4, 1, 7, 3], @@ -782,7 +782,7 @@ public function testAssertWhereContainsWithOutOfOrderMatchingType() $assert->whereContains('foo', [1, 7, 4, 3]); } - public function testAssertWhereContainsWithOutOfOrderNestedMatchingType() + public function testAssertWhereContainsWithOutOfOrderNestedMatchingType(): void { $assert = AssertableJson::fromArray([ ['bar' => 5], @@ -793,7 +793,7 @@ public function testAssertWhereContainsWithOutOfOrderNestedMatchingType() $assert->whereContains('baz', 4); } - public function testAssertWhereContainsWithClosure() + public function testAssertWhereContainsWithClosure(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -804,7 +804,7 @@ public function testAssertWhereContainsWithClosure() }); } - public function testAssertWhereContainsWithNestedClosure() + public function testAssertWhereContainsWithNestedClosure(): void { $assert = AssertableJson::fromArray([ 'foo' => 1, @@ -817,7 +817,7 @@ public function testAssertWhereContainsWithNestedClosure() }); } - public function testAssertWhereContainsWithMultipleClosure() + public function testAssertWhereContainsWithMultipleClosure(): void { $assert = AssertableJson::fromArray([ 'foo' => [1, 2, 3, 4], @@ -833,7 +833,7 @@ function ($actual) { ]); } - public function testAssertWhereContainsWithNullExpectation() + public function testAssertWhereContainsWithNullExpectation(): void { $assert = AssertableJson::fromArray([ 'foo' => 1, @@ -842,7 +842,7 @@ public function testAssertWhereContainsWithNullExpectation() $assert->whereContains('foo', null); } - public function testAssertWhereContainsUsingBackedEnum() + public function testAssertWhereContainsUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'bar' => [BackedEnum::Test->value], @@ -857,7 +857,7 @@ public function testAssertWhereContainsUsingBackedEnum() $assert->whereContains('bar', BackedEnum::TestEmpty); } - public function testAssertWhereContainsFailsUsingBackedEnum() + public function testAssertWhereContainsFailsUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'bar' => [BackedEnum::TestEmpty->value], @@ -869,7 +869,7 @@ public function testAssertWhereContainsFailsUsingBackedEnum() $assert->whereContains('bar', BackedEnum::Test); } - public function testAssertNestedWhereMatchesValue() + public function testAssertNestedWhereMatchesValue(): void { $assert = AssertableJson::fromArray([ 'example' => [ @@ -880,7 +880,7 @@ public function testAssertNestedWhereMatchesValue() $assert->where('example.nested', 'nested-value'); } - public function testAssertNestedWhereFailsWhenDoesNotMatchValue() + public function testAssertNestedWhereFailsWhenDoesNotMatchValue(): void { $assert = AssertableJson::fromArray([ 'example' => [ @@ -894,7 +894,7 @@ public function testAssertNestedWhereFailsWhenDoesNotMatchValue() $assert->where('example.nested', 'another-value'); } - public function testAssertNestedWhereUsingBackedEnum() + public function testAssertNestedWhereUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'example' => [ @@ -905,7 +905,7 @@ public function testAssertNestedWhereUsingBackedEnum() $assert->where('example.nested', BackedEnum::Test); } - public function testAssertNestedWhereFailsUsingBackedEnum() + public function testAssertNestedWhereFailsUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'example' => [ @@ -919,7 +919,7 @@ public function testAssertNestedWhereFailsUsingBackedEnum() $assert->where('example.nested', BackedEnum::Test); } - public function testAssertWhereDoesNotMatchValue() + public function testAssertWhereDoesNotMatchValue(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -928,7 +928,7 @@ public function testAssertWhereDoesNotMatchValue() $assert->whereNot('bar', 'different_value'); } - public function testAssertWhereNotFailsWhenMatchingValue() + public function testAssertWhereNotFailsWhenMatchingValue(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -940,7 +940,7 @@ public function testAssertWhereNotFailsWhenMatchingValue() $assert->whereNot('bar', 'value'); } - public function testAssertWhereNotFailsWhenNotMissing() + public function testAssertWhereNotFailsWhenNotMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -952,7 +952,7 @@ public function testAssertWhereNotFailsWhenNotMissing() $assert->whereNot('baz', 'value'); } - public function testAssertWhereNotUsingClosure() + public function testAssertWhereNotUsingClosure(): void { $assert = AssertableJson::fromArray([ 'bar' => 'baz', @@ -963,7 +963,7 @@ public function testAssertWhereNotUsingClosure() }); } - public function testAssertWhereNotFailsWhenMatchesValueUsingClosure() + public function testAssertWhereNotFailsWhenMatchesValueUsingClosure(): void { $assert = AssertableJson::fromArray([ 'bar' => 'baz', @@ -977,7 +977,7 @@ public function testAssertWhereNotFailsWhenMatchesValueUsingClosure() }); } - public function testAssertWhereNotUsingBackedEnum() + public function testAssertWhereNotUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'bar' => BackedEnum::Test->value, @@ -986,7 +986,7 @@ public function testAssertWhereNotUsingBackedEnum() $assert->whereNot('bar', BackedEnum::TestEmpty); } - public function testAssertWhereNotFailsUsingBackedEnum() + public function testAssertWhereNotFailsUsingBackedEnum(): void { $assert = AssertableJson::fromArray([ 'bar' => BackedEnum::Test->value, @@ -998,7 +998,7 @@ public function testAssertWhereNotFailsUsingBackedEnum() $assert->whereNot('bar', BackedEnum::Test); } - public function testScope() + public function testScope(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1018,7 +1018,7 @@ public function testScope() $this->assertTrue($called, 'The scoped query was never actually called.'); } - public function testScopeFailsWhenPropMissing() + public function testScopeFailsWhenPropMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1035,7 +1035,7 @@ public function testScopeFailsWhenPropMissing() }); } - public function testScopeFailsWhenPropSingleValue() + public function testScopeFailsWhenPropSingleValue(): void { $assert = AssertableJson::fromArray([ 'bar' => 'value', @@ -1048,7 +1048,7 @@ public function testScopeFailsWhenPropSingleValue() }); } - public function testScopeShorthand() + public function testScopeShorthand(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1066,7 +1066,7 @@ public function testScopeShorthand() $this->assertTrue($called, 'The scoped query was never actually called.'); } - public function testScopeShorthandWithoutCount() + public function testScopeShorthandWithoutCount(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1084,7 +1084,7 @@ public function testScopeShorthandWithoutCount() $this->assertTrue($called, 'The scoped query was never actually called.'); } - public function testScopeShorthandFailsWhenAssertingZeroItems() + public function testScopeShorthandFailsWhenAssertingZeroItems(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1101,7 +1101,7 @@ public function testScopeShorthandFailsWhenAssertingZeroItems() }); } - public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch() + public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1118,7 +1118,7 @@ public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch() }); } - public function testScopeShorthandFailsWhenAssertingEmptyArray() + public function testScopeShorthandFailsWhenAssertingEmptyArray(): void { $assert = AssertableJson::fromArray([ 'bar' => [], @@ -1134,7 +1134,7 @@ public function testScopeShorthandFailsWhenAssertingEmptyArray() }); } - public function testScopeShorthandFailsWhenAssertingEmptyArrayWithoutCount() + public function testScopeShorthandFailsWhenAssertingEmptyArrayWithoutCount(): void { $assert = AssertableJson::fromArray([ 'bar' => [], @@ -1150,7 +1150,7 @@ public function testScopeShorthandFailsWhenAssertingEmptyArrayWithoutCount() }); } - public function testScopeShorthandFailsWhenSecondArgumentUnsupportedType() + public function testScopeShorthandFailsWhenSecondArgumentUnsupportedType(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1166,7 +1166,7 @@ public function testScopeShorthandFailsWhenSecondArgumentUnsupportedType() }); } - public function testFirstScope() + public function testFirstScope(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -1182,7 +1182,7 @@ public function testFirstScope() }); } - public function testFirstScopeFailsWhenNoProps() + public function testFirstScopeFailsWhenNoProps(): void { $assert = AssertableJson::fromArray([]); @@ -1193,7 +1193,7 @@ public function testFirstScopeFailsWhenNoProps() }); } - public function testFirstNestedScopeFailsWhenNoProps() + public function testFirstNestedScopeFailsWhenNoProps(): void { $assert = AssertableJson::fromArray([ 'foo' => [], @@ -1208,7 +1208,7 @@ public function testFirstNestedScopeFailsWhenNoProps() }); } - public function testFirstScopeFailsWhenPropSingleValue() + public function testFirstScopeFailsWhenPropSingleValue(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1221,7 +1221,7 @@ public function testFirstScopeFailsWhenPropSingleValue() }); } - public function testEachScope() + public function testEachScope(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -1237,7 +1237,7 @@ public function testEachScope() }); } - public function testEachScopeFailsWhenNoProps() + public function testEachScopeFailsWhenNoProps(): void { $assert = AssertableJson::fromArray([]); @@ -1248,7 +1248,7 @@ public function testEachScopeFailsWhenNoProps() }); } - public function testEachNestedScopeFailsWhenNoProps() + public function testEachNestedScopeFailsWhenNoProps(): void { $assert = AssertableJson::fromArray([ 'foo' => [], @@ -1263,7 +1263,7 @@ public function testEachNestedScopeFailsWhenNoProps() }); } - public function testEachScopeFailsWhenPropSingleValue() + public function testEachScopeFailsWhenPropSingleValue(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1276,7 +1276,7 @@ public function testEachScopeFailsWhenPropSingleValue() }); } - public function testFailsWhenNotInteractingWithAllPropsInScope() + public function testFailsWhenNotInteractingWithAllPropsInScope(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1293,7 +1293,7 @@ public function testFailsWhenNotInteractingWithAllPropsInScope() }); } - public function testDisableInteractionCheckForCurrentScope() + public function testDisableInteractionCheckForCurrentScope(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1307,7 +1307,7 @@ public function testDisableInteractionCheckForCurrentScope() }); } - public function testCannotDisableInteractionCheckForDifferentScopes() + public function testCannotDisableInteractionCheckForDifferentScopes(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1330,7 +1330,7 @@ public function testCannotDisableInteractionCheckForDifferentScopes() }); } - public function testTopLevelPropInteractionDisabledByDefault() + public function testTopLevelPropInteractionDisabledByDefault(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1340,7 +1340,7 @@ public function testTopLevelPropInteractionDisabledByDefault() $assert->has('foo'); } - public function testTopLevelInteractionEnabledWhenInteractedFlagSet() + public function testTopLevelInteractionEnabledWhenInteractedFlagSet(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1355,7 +1355,7 @@ public function testTopLevelInteractionEnabledWhenInteractedFlagSet() ->interacted(); } - public function testAssertWhereAllMatchesValues() + public function testAssertWhereAllMatchesValues(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -1374,7 +1374,7 @@ public function testAssertWhereAllMatchesValues() ]); } - public function testAssertWhereAllFailsWhenAtLeastOnePropDoesNotMatchValue() + public function testAssertWhereAllFailsWhenAtLeastOnePropDoesNotMatchValue(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1392,7 +1392,7 @@ public function testAssertWhereAllFailsWhenAtLeastOnePropDoesNotMatchValue() ]); } - public function testAssertWhereTypeString() + public function testAssertWhereTypeString(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1401,7 +1401,7 @@ public function testAssertWhereTypeString() $assert->whereType('foo', 'string'); } - public function testAssertWhereTypeInteger() + public function testAssertWhereTypeInteger(): void { $assert = AssertableJson::fromArray([ 'foo' => 123, @@ -1410,7 +1410,7 @@ public function testAssertWhereTypeInteger() $assert->whereType('foo', 'integer'); } - public function testAssertWhereTypeBoolean() + public function testAssertWhereTypeBoolean(): void { $assert = AssertableJson::fromArray([ 'foo' => true, @@ -1419,7 +1419,7 @@ public function testAssertWhereTypeBoolean() $assert->whereType('foo', 'boolean'); } - public function testAssertWhereTypeDouble() + public function testAssertWhereTypeDouble(): void { $assert = AssertableJson::fromArray([ 'foo' => 12.3, @@ -1428,7 +1428,7 @@ public function testAssertWhereTypeDouble() $assert->whereType('foo', 'double'); } - public function testAssertWhereTypeArray() + public function testAssertWhereTypeArray(): void { $assert = AssertableJson::fromArray([ 'foo' => ['bar', 'baz'], @@ -1439,7 +1439,7 @@ public function testAssertWhereTypeArray() $assert->whereType('bar', 'array'); } - public function testAssertWhereTypeNull() + public function testAssertWhereTypeNull(): void { $assert = AssertableJson::fromArray([ 'foo' => null, @@ -1448,7 +1448,7 @@ public function testAssertWhereTypeNull() $assert->whereType('foo', 'null'); } - public function testAssertWhereAllType() + public function testAssertWhereAllType(): void { $assert = AssertableJson::fromArray([ 'one' => 'foo', @@ -1471,7 +1471,7 @@ public function testAssertWhereAllType() ]); } - public function testAssertWhereTypeWhenWrongTypeIsGiven() + public function testAssertWhereTypeWhenWrongTypeIsGiven(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1483,7 +1483,7 @@ public function testAssertWhereTypeWhenWrongTypeIsGiven() $assert->whereType('foo', 'integer'); } - public function testAssertWhereTypeWithUnionTypes() + public function testAssertWhereTypeWithUnionTypes(): void { $firstAssert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1497,7 +1497,7 @@ public function testAssertWhereTypeWithUnionTypes() $secondAssert->whereType('foo', ['string', 'null']); } - public function testAssertWhereTypeWhenWrongUnionTypeIsGiven() + public function testAssertWhereTypeWhenWrongUnionTypeIsGiven(): void { $assert = AssertableJson::fromArray([ 'foo' => 123, @@ -1509,7 +1509,7 @@ public function testAssertWhereTypeWhenWrongUnionTypeIsGiven() $assert->whereType('foo', ['string', 'null']); } - public function testAssertWhereTypeWithPipeInUnionType() + public function testAssertWhereTypeWithPipeInUnionType(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1518,7 +1518,7 @@ public function testAssertWhereTypeWithPipeInUnionType() $assert->whereType('foo', 'string|null'); } - public function testAssertWhereTypeWithPipeInWrongUnionType() + public function testAssertWhereTypeWithPipeInWrongUnionType(): void { $assert = AssertableJson::fromArray([ 'foo' => 'bar', @@ -1530,7 +1530,7 @@ public function testAssertWhereTypeWithPipeInWrongUnionType() $assert->whereType('foo', 'integer|null'); } - public function testAssertHasAll() + public function testAssertHasAll(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -1547,7 +1547,7 @@ public function testAssertHasAll() ]); } - public function testAssertHasAllFailsWhenAtLeastOnePropMissing() + public function testAssertHasAllFailsWhenAtLeastOnePropMissing(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -1567,7 +1567,7 @@ public function testAssertHasAllFailsWhenAtLeastOnePropMissing() ]); } - public function testAssertHasAllAcceptsMultipleArgumentsInsteadOfArray() + public function testAssertHasAllAcceptsMultipleArgumentsInsteadOfArray(): void { $assert = AssertableJson::fromArray([ 'foo' => [ @@ -1585,7 +1585,7 @@ public function testAssertHasAllAcceptsMultipleArgumentsInsteadOfArray() $assert->hasAll('foo.bar', 'foo.baz', 'baz'); } - public function testAssertCountMultipleProps() + public function testAssertCountMultipleProps(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1603,7 +1603,7 @@ public function testAssertCountMultipleProps() ]); } - public function testAssertCountMultiplePropsFailsWhenPropMissing() + public function testAssertCountMultiplePropsFailsWhenPropMissing(): void { $assert = AssertableJson::fromArray([ 'bar' => [ @@ -1621,7 +1621,7 @@ public function testAssertCountMultiplePropsFailsWhenPropMissing() ]); } - public function testMacroable() + public function testMacroable(): void { AssertableJson::macro('myCustomMacro', function () { throw new RuntimeException('My Custom Macro was called!'); @@ -1634,7 +1634,7 @@ public function testMacroable() $assert->myCustomMacro(); } - public function testTappable() + public function testTappable(): void { $assert = AssertableJson::fromArray([ 'bar' => [ diff --git a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php index 2698554a2..6e6576e78 100644 --- a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php +++ b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php @@ -146,19 +146,49 @@ public function testMissingDeclaredRegistrarThrows(): void $this->makeRegistrars()->register(); } - public function testDeclaredRegistrarWithoutRegisterMethodThrows(): void + #[DataProvider('nonCallableRegistrarClasses')] + public function testDeclaredRegistrarWithoutCallablePublicStaticRegisterThrows(string $class): void { $this->writeRootComposer(); $this->writeInstalledPackages([ - $this->package('vendor/package', [RegistrarWithoutRegisterMethod::class]), + $this->package('vendor/package', [$class]), ]); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Test-state registrar [' . RegistrarWithoutRegisterMethod::class . '] declared by [vendor/package] must define a register method.'); + $this->expectExceptionMessage('Test-state registrar [' . $class . '] declared by [vendor/package] must define a public static register method.'); $this->makeRegistrars()->register(); } + /** + * Provide registrar classes without a callable public static register method. + * + * @return array + */ + public static function nonCallableRegistrarClasses(): array + { + return [ + 'missing method' => [RegistrarWithoutRegisterMethod::class], + 'public instance method' => [RegistrarWithPublicInstanceRegisterMethod::class], + 'protected static method' => [RegistrarWithProtectedStaticRegisterMethod::class], + 'private static method' => [RegistrarWithPrivateStaticRegisterMethod::class], + 'abstract static method' => [RegistrarWithAbstractStaticRegisterMethod::class], + 'magic static method' => [RegistrarWithMagicStaticRegisterMethod::class], + ]; + } + + public function testInheritedPublicStaticRegisterMethodIsCallable(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/package', [InheritedTestStateRegistrar::class]), + ]); + + $this->makeRegistrars()->register(); + + $this->assertSame(['inherited'], TestStateRegistrarRecorder::$calls); + } + public function testNonStringDeclaredRegistrarThrows(): void { $this->writeRootComposer(); @@ -354,6 +384,69 @@ class RegistrarWithoutRegisterMethod { } +class RegistrarWithPublicInstanceRegisterMethod +{ + /** + * Register test state. + */ + public function register(): void + { + } +} + +class RegistrarWithProtectedStaticRegisterMethod +{ + /** + * Register test state. + */ + protected static function register(): void + { + } +} + +class RegistrarWithPrivateStaticRegisterMethod +{ + /** + * Register test state. + */ + private static function register(): void + { + } +} + +abstract class RegistrarWithAbstractStaticRegisterMethod +{ + /** + * Register test state. + */ + abstract public static function register(): void; +} + +class RegistrarWithMagicStaticRegisterMethod +{ + /** + * Handle dynamic static method calls. + */ + public static function __callStatic(string $name, array $arguments): void + { + } +} + +class ParentTestStateRegistrar +{ + /** + * Register test state. + */ + public static function register(): void + { + TestStateRegistrarRecorder::$calls[] = 'inherited'; + } +} + +class InheritedTestStateRegistrar extends ParentTestStateRegistrar +{ +} + class TestStateRegistrarsProbe extends TestStateRegistrars { /** diff --git a/tests/Testing/PackageMetadataTest.php b/tests/Testing/PackageMetadataTest.php new file mode 100644 index 000000000..98b91de39 --- /dev/null +++ b/tests/Testing/PackageMetadataTest.php @@ -0,0 +1,109 @@ +assertSame('^0.4', $composer['require'][$dependency]); + $this->assertSame('self.version', $rootComposer['replace'][$dependency]); + } + + foreach ([ + 'ext-dom', + 'ext-mbstring', + 'composer-runtime-api', + 'nesbot/carbon', + 'symfony/console', + 'symfony/http-foundation', + 'symfony/process', + 'vlucas/phpdotenv', + ] as $dependency) { + $this->assertSame($rootComposer['require'][$dependency], $composer['require'][$dependency]); + } + + $this->assertSame($rootComposer['require-dev']['mockery/mockery'], $composer['require']['mockery/mockery']); + $this->assertSame([ + 'brianium/paratest' => 'Required to run tests in parallel (^7.24).', + 'phpunit/phpunit' => "Required to use Hypervel's testing assertions and PHPUnit integration (^13.0.3).", + ], $composer['suggest']); + + $this->assertSame( + [TestingServiceProvider::class], + $composer['extra']['hypervel']['providers'], + ); + $this->assertContains( + TestingServiceProvider::class, + $rootComposer['extra']['hypervel']['providers'], + ); + } + + /** + * Ensure active manifests do not declare extensions guaranteed by supported PHP versions. + * + * @throws JsonException + */ + public function testGuaranteedCoreExtensionsAreNotDeclared(): void + { + $paths = array_merge( + [__DIR__ . '/../../composer.json'], + glob(__DIR__ . '/../../src/*/composer.json') ?: [], + ); + + foreach ($paths as $path) { + $composer = json_decode( + file_get_contents($path), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + foreach (['ext-json', 'ext-hash'] as $extension) { + $this->assertArrayNotHasKey($extension, $composer['require'] ?? [], $path); + $this->assertArrayNotHasKey($extension, $composer['suggest'] ?? [], $path); + } + } + } +} diff --git a/tests/Testing/ParallelConsoleOutputTest.php b/tests/Testing/ParallelConsoleOutputTest.php index 49736fa1e..ae70f5d31 100644 --- a/tests/Testing/ParallelConsoleOutputTest.php +++ b/tests/Testing/ParallelConsoleOutputTest.php @@ -10,7 +10,7 @@ class ParallelConsoleOutputTest extends TestCase { - public function testWrite() + public function testWrite(): void { $original = new BufferedOutput; $output = new ParallelConsoleOutput($original); diff --git a/tests/Testing/ParallelRunnerTest.php b/tests/Testing/ParallelRunnerTest.php index 9c33e3cf7..91e86bda2 100644 --- a/tests/Testing/ParallelRunnerTest.php +++ b/tests/Testing/ParallelRunnerTest.php @@ -4,13 +4,16 @@ namespace Hypervel\Tests\Testing; +use Closure; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelRunner; use ParaTest\Options; +use ParaTest\RunnerInterface; use PHPUnit\Framework\Attributes\Test; use ReflectionClass; use ReflectionMethod; @@ -18,24 +21,61 @@ use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Output\BufferedOutput; +use Throwable; class ParallelRunnerTest extends TestCase { - private mixed $originalAppBasePathEnvironment; + /** @var array */ + private array $originalEnvironment; - private mixed $originalAppBasePathServer; + /** @var array */ + private array $originalServer; + + /** @var array */ + private array $originalProcessEnvironment; + + private ContainerContract $originalContainer; protected function setUp(): void { parent::setUp(); - $this->originalAppBasePathEnvironment = $_ENV['APP_BASE_PATH'] ?? null; - $this->originalAppBasePathServer = $_SERVER['APP_BASE_PATH'] ?? null; + $this->originalEnvironment = $this->snapshotArrayValues($_ENV, [ + 'APP_BASE_PATH', + 'COLUMNS', + 'LINES', + 'TEST_TOKEN', + ]); + $this->originalServer = $this->snapshotArrayValues($_SERVER, [ + 'APP_BASE_PATH', + 'HYPERVEL_PARALLEL_TESTING', + 'TEST_TOKEN', + ]); + $this->originalProcessEnvironment = [ + 'COLUMNS' => getenv('COLUMNS'), + 'LINES' => getenv('LINES'), + ]; + $this->originalContainer = Container::getInstance(); } protected function tearDown(): void { - $this->restoreAppBasePath(); + ParallelRunner::resolveApplicationUsing(null); + ParallelRunner::resolveRunnerUsing(null); + ParallelTesting::resolveTokenUsing(null); + + $this->restoreArrayValues($_ENV, $this->originalEnvironment); + $this->restoreArrayValues($_SERVER, $this->originalServer); + + foreach ($this->originalProcessEnvironment as $key => $value) { + if ($value === false) { + putenv($key); + } else { + putenv("{$key}={$value}"); + } + } + + Container::setInstance($this->originalContainer); parent::tearDown(); } @@ -49,14 +89,10 @@ public function itCreatesTheApplicationFromTheInferredBasePath(): void $runner = (new ReflectionClass(ParallelRunner::class))->newInstanceWithoutConstructor(); $method = new ReflectionMethod(ParallelRunner::class, 'createApplication'); - try { - /** @var ApplicationContract $createdApplication */ - $createdApplication = $method->invoke($runner); + /** @var ApplicationContract $createdApplication */ + $createdApplication = $method->invoke($runner); - $this->assertSame($this->app->basePath(), $createdApplication->basePath()); - } finally { - Container::setInstance($this->app); - } + $this->assertSame($this->app->basePath(), $createdApplication->basePath()); } #[Test] @@ -69,14 +105,9 @@ public function itResolvesProcessTokensAsStrings(): void $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); - try { - $method->invoke($runner, function () use (&$tokens): void { - $tokens[] = ParallelTesting::token(); - }); - } finally { - ParallelRunner::resolveApplicationUsing(null); - ParallelTesting::resolveTokenUsing(null); - } + $method->invoke($runner, function () use (&$tokens): void { + $tokens[] = ParallelTesting::token(); + }); $this->assertSame(['1', '2'], $tokens); } @@ -89,34 +120,24 @@ public function itRestoresTheAmbientTokenResolverAfterEachProcess(): void new ParallelRunnerFlushTrackingApplication($this->app->basePath()), new ParallelRunnerFlushTrackingApplication($this->app->basePath()), ]; - $previousServerToken = is_string($_SERVER['TEST_TOKEN'] ?? null) ? $_SERVER['TEST_TOKEN'] : null; - $_SERVER['TEST_TOKEN'] = 'ambient'; ParallelRunner::resolveApplicationUsing(static fn () => array_shift($applications)); $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); - try { - $method->invoke($runner, function () use (&$tokens): void { - $tokens[] = ParallelTesting::token(); - }); + $method->invoke($runner, function () use (&$tokens): void { + $tokens[] = ParallelTesting::token(); + }); - $this->assertSame(['1', '2'], $tokens); - $this->assertSame('ambient', ParallelTesting::token()); - } finally { - ParallelRunner::resolveApplicationUsing(null); - ParallelTesting::resolveTokenUsing(null); - $this->restoreServerTestToken($previousServerToken); - } + $this->assertSame(['1', '2'], $tokens); + $this->assertSame('ambient', ParallelTesting::token()); } #[Test] public function itClearsTheTokenResolverAndFlushesTheApplicationWhenAProcessCallbackFails(): void { $application = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); - $previousServerToken = is_string($_SERVER['TEST_TOKEN'] ?? null) ? $_SERVER['TEST_TOKEN'] : null; - $_SERVER['TEST_TOKEN'] = 'ambient'; ParallelRunner::resolveApplicationUsing(static fn () => $application); @@ -133,29 +154,226 @@ public function itClearsTheTokenResolverAndFlushesTheApplicationWhenAProcessCall $this->assertSame('process callback failed', $exception->getMessage()); $this->assertTrue($application->flushed); $this->assertSame('ambient', ParallelTesting::token()); - } finally { - ParallelRunner::resolveApplicationUsing(null); - ParallelTesting::resolveTokenUsing(null); - $this->restoreServerTestToken($previousServerToken); } } - /** - * Restore the APP_BASE_PATH values. - */ - protected function restoreAppBasePath(): void + #[Test] + public function itRunsSetupRunnerAndTeardownWithFreshApplicationsInTokenOrder(): void { - if ($this->originalAppBasePathEnvironment === null) { - unset($_ENV['APP_BASE_PATH']); - } else { - $_ENV['APP_BASE_PATH'] = $this->originalAppBasePathEnvironment; + $events = []; + $runner = new ParallelRunnerStub(1, onRun: function () use (&$events): void { + $events[] = 'runner'; + }); + $createdApplications = $this->trackingApplications(4); + + ParallelTesting::setUpProcess(function (string $token) use (&$events): void { + $events[] = "setup:{$token}"; + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$events): void { + $events[] = "teardown:{$token}"; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($createdApplications); + + $exitCode = (new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput))->execute(); + + $this->assertSame(1, $exitCode); + $this->assertSame( + ['setup:1', 'setup:2', 'runner', 'teardown:1', 'teardown:2'], + $events, + ); + $this->assertSame(1, $runner->runCount); + $this->assertCount(4, array_unique(array_map(spl_object_id(...), $createdApplications))); + $this->assertContainsOnlyInstancesOf(ParallelRunnerFlushTrackingApplication::class, $createdApplications); + $this->assertSame([true, true, true, true], array_map( + static fn (ParallelRunnerFlushTrackingApplication $application): bool => $application->flushed, + $createdApplications, + )); + } + + #[Test] + public function itTearsDownEverySetupEnteredTokenAfterSetupFails(): void + { + $events = []; + $runner = new ParallelRunnerStub; + $createdApplications = $this->trackingApplications(4); + + ParallelTesting::setUpProcess(function (string $token) use (&$events): void { + $events[] = "setup:{$token}"; + + if ($token === '2') { + throw new RuntimeException('setup failed'); + } + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$events): void { + $events[] = "teardown:{$token}"; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($createdApplications); + + try { + (new ParallelRunner($this->optionsWithProcesses(3), new BufferedOutput))->execute(); + $this->fail('The setup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('setup failed', $exception->getMessage()); } - if ($this->originalAppBasePathServer === null) { - unset($_SERVER['APP_BASE_PATH']); - } else { - $_SERVER['APP_BASE_PATH'] = $this->originalAppBasePathServer; + $this->assertSame(['setup:1', 'setup:2', 'teardown:1', 'teardown:2'], $events); + $this->assertSame(0, $runner->runCount); + $this->assertSame([true, true, true, true], array_map( + static fn (ParallelRunnerFlushTrackingApplication $application): bool => $application->flushed, + $createdApplications, + )); + } + + #[Test] + public function itDoesNotTearDownATokenWhoseApplicationCouldNotBeCreated(): void + { + $events = []; + $runner = new ParallelRunnerStub; + $setupApplication = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + $teardownApplication = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + + ParallelTesting::setUpProcess(function (string $token) use (&$events): void { + $events[] = "setup:{$token}"; + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$events): void { + $events[] = "teardown:{$token}"; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing([ + $setupApplication, + new RuntimeException('application failed'), + $teardownApplication, + ]); + + try { + (new ParallelRunner($this->optionsWithProcesses(3), new BufferedOutput))->execute(); + $this->fail('The application exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('application failed', $exception->getMessage()); } + + $this->assertSame(['setup:1', 'teardown:1'], $events); + $this->assertSame(0, $runner->runCount); + $this->assertTrue($setupApplication->flushed); + $this->assertTrue($teardownApplication->flushed); + } + + #[Test] + public function itPreservesTheRunnerFailureWhileExhaustingTeardown(): void + { + $teardownTokens = []; + $runner = new ParallelRunnerStub(exception: new RuntimeException('runner failed')); + $createdApplications = $this->trackingApplications(4); + + ParallelTesting::tearDownProcess(function (string $token) use (&$teardownTokens): never { + $teardownTokens[] = $token; + + throw new RuntimeException("teardown {$token} failed"); + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($createdApplications); + + try { + (new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput))->execute(); + $this->fail('The runner exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('runner failed', $exception->getMessage()); + } + + $this->assertSame(['1', '2'], $teardownTokens); + $this->assertSame(1, $runner->runCount); + $this->assertSame([true, true, true, true], array_map( + static fn (ParallelRunnerFlushTrackingApplication $application): bool => $application->flushed, + $createdApplications, + )); + } + + #[Test] + public function itThrowsTheFirstTeardownFailureAfterASuccessfulRun(): void + { + $teardownTokens = []; + $runner = new ParallelRunnerStub; + + ParallelTesting::tearDownProcess(function (string $token) use (&$teardownTokens): never { + $teardownTokens[] = $token; + + throw new RuntimeException("teardown {$token} failed"); + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($this->trackingApplications(4)); + + try { + (new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput))->execute(); + $this->fail('The teardown exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('teardown 1 failed', $exception->getMessage()); + } + + $this->assertSame(['1', '2'], $teardownTokens); + $this->assertSame(1, $runner->runCount); + } + + #[Test] + public function itPreservesTheCallbackFailureWhenApplicationFlushAlsoFails(): void + { + $application = new ParallelRunnerFlushTrackingApplication( + $this->app->basePath(), + new RuntimeException('flush failed'), + ); + + $_SERVER['TEST_TOKEN'] = 'ambient'; + ParallelRunner::resolveApplicationUsing(static fn () => $application); + + $runner = new ParallelRunner($this->optionsWithProcesses(1), new BufferedOutput); + $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); + + try { + $method->invoke($runner, static function (): never { + throw new RuntimeException('callback failed'); + }); + $this->fail('The callback exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + + $this->assertTrue($application->flushed); + $this->assertSame('ambient', ParallelTesting::token()); + } + + #[Test] + public function itUsesAnOverriddenSetupLoopButOwnsTeardownByAttemptedToken(): void + { + $setupTokens = []; + $teardownTokens = []; + $runner = new ParallelRunnerStub; + + ParallelTesting::setUpProcess(function (string $token) use (&$setupTokens): void { + $setupTokens[] = $token; + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$teardownTokens): void { + $teardownTokens[] = $token; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($this->trackingApplications(4)); + + $parallelRunner = new ParallelRunnerWithCustomProcesses( + $this->optionsWithProcesses(5), + new BufferedOutput, + ['7', '3'], + ); + + $this->assertSame(0, $parallelRunner->execute()); + $this->assertSame(['7', '3'], $setupTokens); + $this->assertSame(['7', '3'], $teardownTokens); + $this->assertSame(1, $parallelRunner->loopCalls); } /** @@ -178,14 +396,79 @@ protected function optionsWithProcesses(int $processes): Options } /** - * Restore the TEST_TOKEN server value. + * Configure the application resolver with the given sequence. + * + * @param list $applications + */ + protected function resolveApplicationsUsing(array $applications): void + { + ParallelRunner::resolveApplicationUsing(static function () use (&$applications): ApplicationContract { + $application = array_shift($applications); + + if ($application instanceof Throwable) { + throw $application; + } + + return $application; + }); + } + + /** + * Configure the runner resolver. + */ + protected function resolveRunnerUsing(ParallelRunnerStub $runner): void + { + ParallelRunner::resolveRunnerUsing(static fn () => $runner); + } + + /** + * Create flush-tracking applications. + * + * @return list + */ + protected function trackingApplications(int $count): array + { + $applications = []; + + for ($index = 0; $index < $count; ++$index) { + $applications[] = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + } + + return $applications; + } + + /** + * Snapshot selected array values with their presence. + * + * @param array $values + * @param list $keys + * @return array + */ + protected function snapshotArrayValues(array $values, array $keys): array + { + $snapshot = []; + + foreach ($keys as $key) { + $snapshot[$key] = [array_key_exists($key, $values), $values[$key] ?? null]; + } + + return $snapshot; + } + + /** + * Restore selected array values with their original presence. + * + * @param array $values + * @param array $snapshot */ - protected function restoreServerTestToken(?string $token): void + protected function restoreArrayValues(array &$values, array $snapshot): void { - if ($token === null) { - unset($_SERVER['TEST_TOKEN']); - } else { - $_SERVER['TEST_TOKEN'] = $token; + foreach ($snapshot as $key => [$existed, $value]) { + if ($existed) { + $values[$key] = $value; + } else { + unset($values[$key]); + } } } } @@ -194,6 +477,16 @@ class ParallelRunnerFlushTrackingApplication extends Application { public bool $flushed = false; + /** + * Create a flush-tracking application. + */ + public function __construct( + ?string $basePath = null, + private readonly ?Throwable $flushException = null, + ) { + parent::__construct($basePath); + } + /** * Flush the container of all bindings and resolved instances. */ @@ -201,6 +494,70 @@ public function flush(): void { $this->flushed = true; + if ($this->flushException !== null) { + throw $this->flushException; + } + parent::flush(); } } + +class ParallelRunnerStub implements RunnerInterface +{ + public int $runCount = 0; + + /** + * Create a runner stub. + */ + public function __construct( + private readonly int $exitCode = RunnerInterface::SUCCESS_EXIT, + private readonly ?Throwable $exception = null, + private readonly ?Closure $onRun = null, + ) { + } + + /** + * Run the test suite. + */ + public function run(): int + { + ++$this->runCount; + + if ($this->onRun !== null) { + ($this->onRun)(); + } + + if ($this->exception !== null) { + throw $this->exception; + } + + return $this->exitCode; + } +} + +class ParallelRunnerWithCustomProcesses extends ParallelRunner +{ + public int $loopCalls = 0; + + /** + * Create a runner with a custom process sequence. + * + * @param list $tokens + */ + public function __construct(Options $options, BufferedOutput $output, private readonly array $tokens) + { + parent::__construct($options, $output); + } + + /** + * Apply the given callback for each process. + */ + protected function forEachProcess(callable $callback): void + { + ++$this->loopCalls; + + foreach ($this->tokens as $token) { + $this->forProcess($token, $callback); + } + } +} diff --git a/tests/Testing/ParallelTestingTest.php b/tests/Testing/ParallelTestingTest.php index 24698f74a..165b5d063 100644 --- a/tests/Testing/ParallelTestingTest.php +++ b/tests/Testing/ParallelTestingTest.php @@ -8,6 +8,7 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use stdClass; class ParallelTestingTest extends TestCase @@ -43,7 +44,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testTokenReturnsFalseWhenNotRunningInParallel() + public function testTokenReturnsFalseWhenNotRunningInParallel(): void { $parallelTesting = new ParallelTesting(new Container); @@ -52,7 +53,7 @@ public function testTokenReturnsFalseWhenNotRunningInParallel() $this->assertFalse($parallelTesting->token()); } - public function testTokenReturnsValueFromResolver() + public function testTokenReturnsValueFromResolver(): void { $parallelTesting = new ParallelTesting(new Container); @@ -61,7 +62,7 @@ public function testTokenReturnsValueFromResolver() $this->assertSame('3', $parallelTesting->token()); } - public function testInParallelReturnsFalseWithoutToken() + public function testInParallelReturnsFalseWithoutToken(): void { $parallelTesting = new ParallelTesting(new Container); @@ -71,7 +72,7 @@ public function testInParallelReturnsFalseWithoutToken() $this->assertFalse($parallelTesting->inParallel()); } - public function testInParallelReturnsFalseWithoutServerVariable() + public function testInParallelReturnsFalseWithoutServerVariable(): void { $parallelTesting = new ParallelTesting(new Container); @@ -81,7 +82,7 @@ public function testInParallelReturnsFalseWithoutServerVariable() $this->assertFalse($parallelTesting->inParallel()); } - public function testInParallelReturnsTrueWithTokenAndServerVariable() + public function testInParallelReturnsTrueWithTokenAndServerVariable(): void { $parallelTesting = new ParallelTesting(new Container); @@ -91,7 +92,7 @@ public function testInParallelReturnsTrueWithTokenAndServerVariable() $this->assertTrue($parallelTesting->inParallel()); } - public function testOptionReturnsFalseByDefault() + public function testOptionReturnsFalseByDefault(): void { $parallelTesting = new ParallelTesting(new Container); @@ -99,7 +100,7 @@ public function testOptionReturnsFalseByDefault() $this->assertFalse($parallelTesting->option('without_databases')); } - public function testOptionUsesCustomResolver() + public function testOptionUsesCustomResolver(): void { $parallelTesting = new ParallelTesting(new Container); @@ -109,7 +110,7 @@ public function testOptionUsesCustomResolver() $this->assertFalse($parallelTesting->option('without_databases')); } - public function testOptionResolverCanBeReset() + public function testOptionResolverCanBeReset(): void { $parallelTesting = new ParallelTesting(new Container); @@ -120,7 +121,7 @@ public function testOptionResolverCanBeReset() $this->assertFalse($parallelTesting->option('anything')); } - public function testSetUpTestCaseCallbacksNotCalledWithoutParallelTesting() + public function testSetUpTestCaseCallbacksNotCalledWithoutParallelTesting(): void { $parallelTesting = new ParallelTesting(new Container); @@ -137,7 +138,7 @@ public function testSetUpTestCaseCallbacksNotCalledWithoutParallelTesting() $this->assertFalse($called); } - public function testSetUpTestCaseCallbacksCalledWithToken() + public function testSetUpTestCaseCallbacksCalledWithToken(): void { $parallelTesting = new ParallelTesting(new Container); @@ -157,7 +158,7 @@ public function testSetUpTestCaseCallbacksCalledWithToken() $this->assertSame($this, $receivedTestCase); } - public function testTearDownTestCaseCallbacksNotCalledWithoutParallelTesting() + public function testTearDownTestCaseCallbacksNotCalledWithoutParallelTesting(): void { $parallelTesting = new ParallelTesting(new Container); @@ -174,7 +175,7 @@ public function testTearDownTestCaseCallbacksNotCalledWithoutParallelTesting() $this->assertFalse($called); } - public function testTearDownTestCaseCallbacksCalledWithToken() + public function testTearDownTestCaseCallbacksCalledWithToken(): void { $parallelTesting = new ParallelTesting(new Container); @@ -194,7 +195,7 @@ public function testTearDownTestCaseCallbacksCalledWithToken() $this->assertSame($this, $receivedTestCase); } - public function testMultipleCallbacksAreCalledInOrder() + public function testMultipleCallbacksAreCalledInOrder(): void { $parallelTesting = new ParallelTesting(new Container); @@ -217,7 +218,7 @@ public function testMultipleCallbacksAreCalledInOrder() $this->assertSame(['first', 'second', 'third'], $order); } - public function testCallbacksReceiveCorrectTokenValue() + public function testCallbacksReceiveCorrectTokenValue(): void { $parallelTesting = new ParallelTesting(new Container); @@ -238,7 +239,7 @@ public function testCallbacksReceiveCorrectTokenValue() $this->assertSame(['5', '10'], $tokens); } - public function testTokenResolverCanBeReset() + public function testTokenResolverCanBeReset(): void { $parallelTesting = new ParallelTesting(new Container); @@ -253,8 +254,99 @@ public function testTokenResolverCanBeReset() $this->assertFalse($parallelTesting->inParallel()); } + public function testTearDownProcessCallbacksContinueAfterFailuresAndThrowTheFirstFailure(): void + { + $parallelTesting = new ParallelTesting(new Container); + $callbacks = []; + + $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; + $parallelTesting->resolveTokenUsing(fn () => '4'); + + $parallelTesting->tearDownProcess(function (string $token) use (&$callbacks): never { + $callbacks[] = "first:{$token}"; + + throw new RuntimeException('first failure'); + }); + $parallelTesting->tearDownProcess(function (string $token) use (&$callbacks): void { + $callbacks[] = "second:{$token}"; + }); + $parallelTesting->tearDownProcess(function (string $token) use (&$callbacks): never { + $callbacks[] = "third:{$token}"; + + throw new RuntimeException('third failure'); + }); + + try { + $parallelTesting->callTearDownProcessCallbacks(); + $this->fail('The teardown exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('first failure', $exception->getMessage()); + } + + $this->assertSame(['first:4', 'second:4', 'third:4'], $callbacks); + } + + public function testTearDownTestCaseCallbacksContinueAfterFailuresAndThrowTheFirstFailure(): void + { + $parallelTesting = new ParallelTesting(new Container); + $callbacks = []; + + $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; + $parallelTesting->resolveTokenUsing(fn () => '6'); + + $parallelTesting->tearDownTestCase(function (string $token, mixed $testCase) use (&$callbacks): never { + $this->assertSame($this, $testCase); + $callbacks[] = "first:{$token}"; + + throw new RuntimeException('first failure'); + }); + $parallelTesting->tearDownTestCase(function (string $token, mixed $testCase) use (&$callbacks): void { + $this->assertSame($this, $testCase); + $callbacks[] = "second:{$token}"; + }); + + try { + $parallelTesting->callTearDownTestCaseCallbacks($this); + $this->fail('The teardown exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('first failure', $exception->getMessage()); + } + + $this->assertSame(['first:6', 'second:6'], $callbacks); + } + + public function testSetUpCallbacksRemainFailFast(): void + { + $parallelTesting = new ParallelTesting(new Container); + $callbacks = []; + + $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; + $parallelTesting->resolveTokenUsing(fn () => '8'); + + $parallelTesting->setUpProcess(function () use (&$callbacks): void { + $callbacks[] = 'first'; + }); + $parallelTesting->setUpProcess(function () use (&$callbacks): never { + $callbacks[] = 'second'; + + throw new RuntimeException('setup failed'); + }); + $parallelTesting->setUpProcess(function () use (&$callbacks): void { + $callbacks[] = 'third'; + }); + + try { + $parallelTesting->callSetUpProcessCallbacks(); + $this->fail('The setup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('setup failed', $exception->getMessage()); + } + + $this->assertSame(['first', 'second'], $callbacks); + } + #[DataProvider('allCallbackTypes')] - public function testAllCallbackTypesFireWhenInParallel(string $callback, array $callerArgs) + public function testAllCallbackTypesFireWhenInParallel(string $callback, array $callerArgs): void { $parallelTesting = new ParallelTesting(new Container); $caller = 'call' . ucfirst($callback) . 'Callbacks'; diff --git a/tests/Testing/Profile/ExecutionFinishedSubscriberTest.php b/tests/Testing/Profile/ExecutionFinishedSubscriberTest.php new file mode 100644 index 000000000..b324ebf12 --- /dev/null +++ b/tests/Testing/Profile/ExecutionFinishedSubscriberTest.php @@ -0,0 +1,109 @@ +start('test-id', 1.0); + $tracker->stop('test-id', 'Example test', 2.0); + + $subscriber = new ExecutionFinishedSubscriber($tracker, 'profile-write://directory'); + $event = (new ReflectionClass(ExecutionFinished::class))->newInstanceWithoutConstructor(); + + stream_wrapper_register('profile-write', ProfileWriteStreamWrapper::class); + + try { + $subscriber->notify($event); + $this->fail('The incomplete profile write was not rejected.'); + } catch (RuntimeException $exception) { + $this->assertStringStartsWith( + 'Unable to write test profile [profile-write://directory/profile-', + $exception->getMessage(), + ); + } finally { + stream_wrapper_unregister('profile-write'); + } + } + + /** + * Provide incomplete write modes. + * + * @return array + */ + public static function failedWrites(): array + { + return [ + 'false' => ['false'], + 'short' => ['short'], + ]; + } +} + +class ProfileWriteStreamWrapper +{ + /** @var null|resource */ + public mixed $context = null; + + public static string $writeMode = 'false'; + + public static int $writeCount = 0; + + /** + * Open the stream. + */ + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + return true; + } + + /** + * Write bytes to the stream. + */ + public function stream_write(string $data): int|false + { + if (static::$writeMode === 'false' || static::$writeCount++ > 0) { + return false; + } + + return max(0, strlen($data) - 1); + } + + /** + * Return stream metadata. + * + * @return array{mode: int} + */ + public function stream_stat(): array + { + return ['mode' => 0100666]; + } + + /** + * Return URL metadata. + * + * @return array{mode: int} + */ + public function url_stat(string $path, int $flags): array + { + return ['mode' => 0040777]; + } +} diff --git a/tests/Testing/SeeInHtmlTest.php b/tests/Testing/SeeInHtmlTest.php new file mode 100644 index 000000000..c879834f8 --- /dev/null +++ b/tests/Testing/SeeInHtmlTest.php @@ -0,0 +1,144 @@ +assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue((new SeeInHtml('

Hello World

'))->matches(['Hello World'])); + } + + #[DataProvider('unicodeWhitespaceCharacters')] + public function testCollapsesRawUnicodeWhitespace(string $whitespace): void + { + $constraint = new SeeInHtml('Hello World'); + + $this->assertTrue($constraint->matches(["

Hello{$whitespace}World

"])); + } + + /** + * Provide Unicode whitespace characters. + * + * @return array + */ + public static function unicodeWhitespaceCharacters(): array + { + return [ + 'no-break space (U+00A0)' => ["\u{00A0}"], + 'en space (U+2002)' => ["\u{2002}"], + 'em space (U+2003)' => ["\u{2003}"], + 'thin space (U+2009)' => ["\u{2009}"], + 'ideographic space (U+3000)' => ["\u{3000}"], + ]; + } + + public function testCollapsesMultipleAsciiWhitespace(): void + { + $constraint = new SeeInHtml('Hello World'); + + $this->assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue($constraint->matches(["

Hello\tWorld

"])); + $this->assertTrue($constraint->matches(["

Hello\nWorld

"])); + $this->assertTrue($constraint->matches(["

Hello \t\n World

"])); + } + + public function testFailsWhenValueIsAbsent(): void + { + $constraint = new SeeInHtml('Hello World'); + + $this->assertFalse($constraint->matches(['

Goodbye World

'])); + } + + public function testNegateInvertsTheAssertion(): void + { + $constraint = new SeeInHtml('Hello World', ordered: false, negate: true); + + $this->assertTrue($constraint->matches(['

Goodbye World

'])); + $this->assertFalse($constraint->matches(['

Hello World

'])); + } + + public function testOrderedRespectsSequenceAcrossUnicodeWhitespace(): void + { + $constraint = new SeeInHtml('Hello beautiful World', ordered: true); + + $this->assertTrue($constraint->matches(['Hello', 'beautiful', 'World'])); + $this->assertFalse($constraint->matches(['World', 'Hello'])); + } + + public function testAssertsStringZeroAndSkipsOnlyTheRawEmptyString(): void + { + $constraint = new SeeInHtml('

0

'); + + $this->assertTrue($constraint->matches(['', '0'])); + $this->assertFalse((new SeeInHtml('

one

'))->matches(['0'])); + } + + public function testRejectsExpectedValuesWithoutVisibleText(): void + { + $constraint = new SeeInHtml('

Hello World

'); + + $this->assertFalse($constraint->matches([" \t\n "])); + $this->assertFalse($constraint->matches([''])); + $this->assertFalse((new SeeInHtml('

Hello World

', negate: true))->matches(['
'])); + } + + public function testRetainsByteWiseMatchingForMalformedUtf8(): void + { + $constraint = new SeeInHtml("

Hello \xFF World

"); + + $this->assertTrue($constraint->matches(["Hello \xFF World"])); + $this->assertFalse($constraint->matches(["Goodbye \xFF World"])); + } + + public function testFailureMessagesContainOnePrefixAndOneTerminalPeriod(): void + { + $this->assertConstraintFailure( + new SeeInHtml('Hello World'), + ['Goodbye World'], + 'Failed asserting that \'Hello World\' contains "Goodbye World".', + ); + $this->assertConstraintFailure( + new SeeInHtml('Hello World', ordered: true), + ['World', 'Hello'], + 'Failed asserting that \'Hello World\' contains "Hello" in specified order.', + ); + $this->assertConstraintFailure( + new SeeInHtml('Hello World', negate: true), + ['Hello World'], + 'Failed asserting that \'Hello World\' does not contain "Hello World".', + ); + $this->assertConstraintFailure( + new SeeInHtml('Hello World'), + [''], + 'Failed asserting that the expected value "" contains visible text.', + ); + } + + /** + * Assert a constraint fails with the expected complete PHPUnit message. + * + * @param list $values + */ + protected function assertConstraintFailure(SeeInHtml $constraint, array $values, string $message): void + { + try { + $constraint->evaluate($values); + $this->fail('The constraint did not fail.'); + } catch (ExpectationFailedException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } +} diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index ded3b766b..becccee84 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -4,12 +4,20 @@ namespace Hypervel\Tests\Testing\TestResponseTest; +use Exception; +use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Contracts\View\View; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Http\IterableStreamedResponse; +use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\Session\ArraySessionHandler; +use Hypervel\Session\Store; +use Hypervel\Support\Collection; +use Hypervel\Support\MessageBag; +use Hypervel\Support\ViewErrorBag; use Hypervel\Testing\TestResponse; use Hypervel\Tests\TestCase; use Mockery as m; @@ -259,6 +267,336 @@ public function testHeadStreamedContentDoesNotInvokeIterableProducer(): void $this->assertSame(0, $invocations); } + public function testRenderedTextAssertionsNormalizeMarkupEntitiesAndWhitespace(): void + { + $response = TestResponse::fromBaseResponse(new Response( + "

Hello beautiful\u{2003}World

0
" + )); + + $response + ->assertSee(['beautiful', 'World']) + ->assertSeeHtml(['
', 'beautiful']) + ->assertSeeHtmlInOrder(['
', '0']) + ->assertSeeText(['Hello beautiful World', '0']) + ->assertSeeTextInOrder(['Hello', 'beautiful', 'World', '0']) + ->assertDontSee(['Goodbye', '