diff --git a/composer.json b/composer.json index ea66bd9eb1..f63ca001a2 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "aws/aws-sdk-php": "^3.322.9", "brick/math": "^1.0", "chillerlan/php-qrcode": "^6.0", + "composer/semver": "^3.5.1", "defuse/php-encryption": "^2.4", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -60,6 +61,7 @@ "guzzlehttp/uri-template": "^1.0 || ^2.0", "hypervel/guzzle": "^8.3", "hypervel/guzzle-promises": "^3.1", + "laravel/agent-detector": "^2.0.5", "laravel/serializable-closure": "^2.0.11", "lcobucci/jwt": "^5.6", "league/commonmark": "^2.10.3", @@ -121,7 +123,6 @@ "algolia/algoliasearch-client-php": "^4.49.0", "brianium/paratest": "^7.27.2", "composer/composer": "^2.10.3", - "composer/semver": "^3.4", "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", "intervention/image": "^4.2", @@ -168,6 +169,7 @@ "hypervel/database": "self.version", "hypervel/di": "self.version", "hypervel/docs": "self.version", + "hypervel/doctor": "self.version", "hypervel/encryption": "self.version", "hypervel/engine": "self.version", "hypervel/events": "self.version", @@ -259,6 +261,7 @@ "Hypervel\\Data\\": "src/data/src/", "Hypervel\\Database\\": "src/database/src/", "Hypervel\\Di\\": "src/di/src/", + "Hypervel\\Doctor\\": "src/doctor/src/", "Hypervel\\Encryption\\": "src/encryption/src/", "Hypervel\\Engine\\": "src/engine/src/", "Hypervel\\Events\\": "src/events/src/", @@ -399,6 +402,7 @@ "Hypervel\\Cookie\\CookieServiceProvider", "Hypervel\\Data\\DataServiceProvider", "Hypervel\\Database\\DatabaseServiceProvider", + "Hypervel\\Doctor\\DoctorServiceProvider", "Hypervel\\Encryption\\EncryptionServiceProvider", "Hypervel\\Engine\\EngineServiceProvider", "Hypervel\\Events\\EventServiceProvider", diff --git a/docs/plans/2026-10-21-doctor-port.md b/docs/plans/2026-10-21-doctor-port.md new file mode 100644 index 0000000000..ab7cfaf973 --- /dev/null +++ b/docs/plans/2026-10-21-doctor-port.md @@ -0,0 +1,384 @@ +# Hypervel Doctor port + +## Status and authorization + +Target branch: `feature/doctor`, based on `0.4`. All paths below are relative to the components repository unless otherwise stated. + +This is a new package port plus the framework corrections described below. Implementation, the timezone architectural adaptation, and the shared Redis requirements API are authorized. API-compatible correctness fixes and their supporting refactors do not need separate approval, including those in other framework packages. This plan alone does not authorize staging, committing, or pushing. + +The package and diagnostic and instance-lifetime corrections are implemented and verified. Remaining work: publish the latest corrections and finish both repositories' CI and review workflows before merging. + +The additive `config:show --json`, `Migrator::getPendingMigrations()`, and `DotenvManager::getLoadedVariableNames()` APIs are approved. Preserve existing command behavior and protected extension points. + +Follow the repository's `AGENTS.md` and other applicable instructions; this plan does not override them. + +## Approved architectural adaptation + +| # | Approved decision | Reason | +|---|---|---| +| 1 | Omit public `ApplicationTimezoneIsValid` and the command's timezone termination workaround. | Invalid application timezone fails during framework boot, before Doctor. A bypass would complicate bootstrap for no useful diagnostic benefit. | + +## Doctor enhancements + +These are diagnostic coverage/design additions within the port, not Laravel API removals. The shared cross-package Redis API is approved: + +| Enhancement | Reason | +|---|---| +| Check installed dependencies' extension requirements, versions, and providers/replacements. | Read framework requirements such as the Swoole minimum from Composer metadata rather than duplicating constants. | +| Check installed dependencies' PHP constraints. | Composer's generated runtime check does not enforce upper bounds and can be disabled. | +| Warn about degraded failover capacity. | Report unavailable alternatives even when another cache/queue backend remains usable. Detecting total failure is a correctness fix. | +| Add conditional Redis feature checks using shared Redis-owned requirements. | Diagnose only the capabilities used by active services, without hot-path network work. | +| Check required Mercure broadcaster settings. | Cover Hypervel's first-party broadcaster through the active-driver configuration diagnostic. | + +The remaining changes follow the porting policy: modern types, coroutine safety, supported Hypervel equivalents, and verified correctness fixes. This includes production environment-file handling, actual Git protection checks, effective cache-table repairs, the Redis-session fallback, migration isolation, and shared constants supporting those fixes. Any additional public/protected API departure, capability removal, schema change, or material performance tradeoff discovered during implementation must return to the owner. Do not silently substitute a workaround when approval is needed. + +## References and package structure + +Reference package: [Laravel Doctor](https://github.com/laravel/doctor), using the current checked-out upstream source. Preserve its class names, method/parameter names, protected extension points, file layout, selection semantics, result structures, rendering formats, and fix callbacks unless this plan explicitly identifies a difference for approval. + +Create `src/doctor`, namespace `Hypervel\Doctor`, package `hypervel/doctor`. Use `src/cache/{composer.json,LICENSE.md,README.md}` for the first-party package skeleton. Copy upstream files with `cp`, then read and edit each copy individually. Do not recreate ported files from selected fragments or run bulk rewrite scripts. + +Wire both Composer manifests: + +- Split package PSR-4 namespace, dependencies, branch alias, and `extra.hypervel.providers`. +- Root PSR-4 mapping, `replace`, and provider discovery entry. Add optional Doctor through discovery, not `DefaultProviders` or early bootstrap. +- Keep the facade and upstream provider registrations. Remove only the redundant concrete `ComposerJson` singleton binding; keep the `Doctor` factory that installs the default diagnostics. +- Use Hypervel console, contracts, process, support, prompts, database, cache, session, filesystem, Redis and other directly used split packages. Verify the final direct imports/helpers against their owning packages; the development root's broad dependencies are not evidence that the split package is complete. +- Retain `composer-runtime-api` and `composer/semver`. Add `laravel/agent-detector` as a standalone dependency, preserving its actual namespace. Check Packagist for the latest compatible stable release immediately before `composer require`; do not invent a Hypervel port of this independent package. +- Do not add `composer/composer`, Pest, a dependency solver, or a generic subprocess adapter. +- Root dependency changes use Composer commands. Root `composer.lock` stays untracked. Update split manifest requirements directly, as permitted for unlocked monorepo packages. +- Audit touched framework manifests too: the extracted Redis requirements become a direct Foundation dependency; declare `hypervel/redis` there rather than relying only on Session's transitive requirement. Database already requires Context for the migration state change. + +Copy `config/doctor.php`, retaining `only`, `except`, and environment-mode behavior. Convert framework names and examples. Keep the diagnostic generator and both stubs, with strict types and modern Hypervel signatures. + +User documentation belongs in `src/docs/doctor.md`, adapted from upstream README. Add its navigation entry in `src/docs/documentation.md`. Keep the package README minimal with documentation/upstream links and only deliberate public differences. Record the approved timezone omission in README, a source comment at its registration/workaround locations, and a test omission note naming the upstream file in the shared TestCase. Add a concise porting-guide entry for existing Laravel Doctor usage that needs adaptation, including `EnvironmentMode::fromHypervelEnvironment()` alongside the namespace change; do not turn that guide into a bug-fix inventory. + +## Lifetimes, cancellation, and command behavior + +| State | Ownership and implementation | +|---|---| +| Registered diagnostic classes | Worker-lived `Doctor` registry. Add `Boot-only.` warnings to its registration methods. | +| Diagnostic invocation | In `Doctor::selected()`, resolve through `makeTransient($class)`. This reproduces Laravel's fresh implicit resolutions while respecting explicit application bindings. | +| Pending run and result objects | Construct per invocation as upstream does. Do not add blanket `Transient` markers. | +| Root `ComposerJson` contents | Keep existing instance memoization. Bundled fixes do not modify root composer.json. | +| Installed dependency metadata | Fresh snapshot once per diagnostic check, including the post-fix pass. Never worker-cache health verdicts or the installed graph. | +| Diagnostic source attribution | Keep upstream class-keyed metadata cache, naturally bounded by application classes. Add `flushState()` and optional-package cleanup in `AfterEachTestSubscriber`. | +| CLI renderers | Retain command-owned rendering state. Hypervel already clones command prototypes for normal and nested runs; do not add another command lifecycle layer. | + +Derive DiagnosticSource's built-in diagnostic namespace as `__NAMESPACE__ . '\\Diagnostics\\'` rather than another literal namespace. + +`Doctor::check()` and `fix()` must rethrow `Swoole\Coroutine\CanceledException` unchanged before converting ordinary failures into results. Apply this to the following diagnostic I/O catches, where swallowing cancellation would continue work or produce a misleading result: + +- `CacheStoreIsReachable`: check probe, fix probe, `fixOptions`, and `cacheTableIsMissing`. +- `DatabaseConnectionIsReachable`, `FilesystemDisksAreReachable`, `MigrationsAreUpToDate`, `QueueConnectionIsReachable`, `RedisConnectionsAreReachable`, and `SessionDriverIsReachable`. +- New failover child probes and `RedisSupportsConfiguredFeatures`. + +Keep owned probe cleanup in `finally`; cleanup errors must not replace an existing failure or cancellation. Do not blanket-edit non-I/O reflection catches or the separate manual `cache:redis-doctor` command's cancellation behavior. + +Use `Hypervel\Console\CommandInput` and remove the Laravel-version compatibility TODO: `boolean()` for flags, `array()` for selectors, and `string()->toString()` for format/exit-threshold strings. `string()` returns a Stringable object, not a native string. Preserve option validation and explicit-format detection. Hypervel Prompts supports coroutine task animation; remove the `pcntl_fork` requirement. Keep interactive/noninteractive and JSON/GitHub restrictions. Correct `fixDecision()` before any prompt path: + +```php +if ($this->input()->boolean('fix')) { + return ! $outcome->fixRequiresOption(); +} +``` + +Thus `--fix` applies unambiguous fixes and skips choice-based fixes even with interactive input; an explicit interactive run can still choose an option. Do not select a store on the user's behalf. + +Before prompting without `--fix`, require both interactive command input and interactive Prompts state; piped input must not accept a repair automatically. Preserve numeric fix-option keys when adding the decline choice, and pass the selected key as a string. + +Write JSON, agent JSON, and GitHub annotations as raw console output so diagnostic text retains literal markup and backslashes, including with decorated output. Apply the same correction to `route:list --json`, `queue:monitor --json`, `queue:failed --json`, and queue-worker JSON pause/resume events; preserve human formatting. + +Keep upstream repairs and scope restrictions, translating commands to Hypervel. `make:cache-table` is canonical. Scheduler advice uses Hypervel's long-running `schedule:run`; do not recommend nonexistent `schedule:work` or an every-minute launcher for the long-running command. Background/deferred queues pass the asynchronous diagnostic in both environment modes with in-process coroutine wording and no worker advice; ordinary external queues retain worker advice. Apply this to the first concrete failover child as well. + +Honor the queue manager's built-in `null` connection without requiring a configuration record. Reachability can resolve it normally; the asynchronous diagnostic skips with an explicit explanation that null queues discard jobs, including named null drivers and null failover children. + +`Link::docs()` uses ordinary `https://hypervel.org/docs/...` URLs in all renderers. Hypervel's `.md` endpoints are not available. Verify every translated link and anchor against `src/docs`. + +Programmatic checks remain supported. Repairs that write deployment files or mutate global configuration are maintenance operations; document that they must not run concurrently with application traffic. Do not add a subprocess repair framework or weaken the existing fix API. Existing Process execution already returns idle DB connections before waiting; do not duplicate pool-release calls throughout Doctor. + +## Infrastructure diagnostics + +### Database and SQLite + +Resolve the registered `db.factory`, preserving `DB::extend()` custom factories. Use its public `parseConfig($configuration, $name)` for normalized driver detection, SQLite handling and timeout defaults; this owns database URL parsing and name/prefix defaults. Build the standalone probe with that factory's `make()`. Do not duplicate normalization with Doctor-owned parser calls or purge/disconnect the application's cached connection. + +```php +$connection = app('db.factory')->make($configuration, $name); + +try { + $connection->getServerVersion(); +} finally { + $connection->disconnect(); +} +``` + +Use the framework's driver-neutral API: `getPdo()` exists only on PDO connections; `getServerVersion()` belongs to the driver-neutral connection contract. Preserve an explicitly configured PDO timeout and upstream's default probe timeout where applicable; that option does not bound custom transports, which own their timeouts. Do not invent a driver registry or cast a custom connection to PDO. Remove SQL Server-specific probing. + +Parse configured default role suffixes with `ConnectionName`, looking up the base name and following `DatabaseManager`'s role selection. Keep the configured default rather than adopting the coroutine's ambient default. Preserve non-array PDO options so the owning connector reports invalid configuration; never inject PDO options into a selected custom extension. + +For built-in split connections, check every selectable read/write record for the requested role. Keep base probes on the full configuration with one raw record selected: `getServerVersion()` opens writes, and `PdoConnection::getReadPdo()` opens reads. Standalone merged read configurations would wrongly invoke extensions that normal base connections bypass. Explicit read aliases retain the manager's selected-config extension resolution. Reuse factory merging, preserve connector host-array failover, and apply timeout defaults to effective record options as well as base options. Report failures by role and record index without dumping configuration. No new factory hook or pool change is needed. + +Identify drivers from normalized configuration, not connection names. A connection named `analytics` can be SQLite. Use `SQLiteDatabase::isInMemory()` and `isUri()` so `:memory:` and supported `file:` URI forms never become bogus local files. A URI is handled by the SQLite connector, not by Doctor's plain-file creation repair. Keep schema probes cheap (`hasTable`), without counting application tables or reading application rows. + +SQLite file inspection uses the effective selected role, including `configForWrite()` when the factory has a read side. Keep the inspected path in the repair result; do not repair a different file after configuration changes. + +Create missing SQLite files exclusively so a file created between inspection and repair is never truncated; an existing regular file needs no recreation. + +### Migrations: fix the owning state first + +`DatabaseServiceProvider` registers both `migrator` and `migration.repository` as singletons. Their connection fields currently override the coroutine default. A verified interleaving is A `usingConnection('tenant_a')`, B `usingConnection('tenant_b')`, then A querying the repository: A's context is `tenant_a` but its repository resolves `tenant_b`. Restoration can also leave stale connection state. Concurrent `queue:work --concurrency` provisioning jobs calling migration commands exercise this path. `migrate:install` directly changes repository source as well. + +Implement the migration correctness fix as follows: + +- Add `Migrations/MigratorState` with nullable `connection` and `OutputStyle` fields. Implement `ReplicableContext::replicate()` by cloning the state, so `Coroutine::fork()` inherits values without sharing subsequent field mutation. +- Preserve `Migrator::$connection` and `$output` as protected typed properties through PHP 8.4 hooks. Reads use `currentState()?->...` without allocating state; setters initialize state when needed. +- Store state in `CoroutineContext` under an owning key prefix plus `spl_object_id($this)`, with a `WeakReference` to the owning migrator. Accept state only when its owner is the current instance, replacing stale entries on initialization. Copies retain the weak owner but clone mutable state. Constructor/clone resets alone cannot clear stale entries in other coroutines. Outside coroutines keep instance-owned state; do not add client-resource cleanup machinery. +- Make `DatabaseMigrationRepository::$connection` a corresponding hooked nullable property. Store a weak owner and connection pair in context, validating ownership on reads; keep the non-coroutine instance field. No second state class is needed. +- Preserve `usingConnection()`'s independent stored-connection and default-context snapshots/restoration. Keep the repository interface and custom bindings; custom implementations own their own coroutine safety. +- Keep migration paths, resolver callbacks, exclusions and immutable file metadata worker-lived. Do not scope the entire migrator, which would lose boot-registered paths. Do not lock all migrations or clone repositories inside Doctor. +- Correct the existing docblock that incorrectly claims no worker-global mutation. Update any touched public worker configuration mutator warnings. + +`InstallCommand` needs no extra restoration mechanism: its repository source now belongs only to the calling coroutine. Retain its existing same-coroutine behavior. + +Property shape: + +```php +protected ?string $connection { + get => $this->currentState()?->connection; + set { $this->state()->connection = $value; } +} +``` + +Doctor then obtains migration files from registered paths plus `database_path('migrations')`, and inspects the repository inside `usingConnection(null, ...)`. This applies `migrations_connection` consistently with `migrate:status`, without changing ambient context afterward. Preserve the local-only repair, using `Artisan::call('migrate', ['--no-interaction' => true])` so it repairs the inspected application's configuration and registered paths. Keep the existing refusal to create a missing database without `--force`. Rethrow cancellation; convert other command exceptions and nonzero exits into the existing failed-fix result with useful details. + +Expose `Migrator::getPendingMigrations(array $files, array $ran): array` by delegating to the existing protected `pendingMigrations()` method. Doctor uses that result so registered exclusions and subclass filtering remain authoritative; do not duplicate exclusion normalization or widen the protected method's visibility. + +### Active services and wrappers + +Extend existing `Support/ActiveDrivers`, alongside `logChannels()` and `mailers()`, with `cacheStores(string $store, array $seen = []): array`, `queueConnections(string $connection, array $seen = []): array`, and `redisConnections(): array`, returning ordered lists of names. Keep the existing static-helper style. Walk only selected services, not every configured tenant/inactive store. Use path-local cycle detection and deduplicate names while preserving configured order. These discovery lists do not replace wrapper-aware reachability verdicts. + +- Cache `stack` accepts numeric store names and named entries with per-layer options; follow `CacheManager::createStackDriver()`'s actual shape, including enum names and null selecting the default store. Cache failover children retain their string-name contract. +- Failover cache uses configured child stores; failover queue uses child connections. Redis discovery unwraps both wrapper types, and required service-configuration checks follow active failover queue branches. +- Logging stack discovery accepts comma-separated names, trims names, and normalizes enum names as LogManager does. Inline logger objects have no configured channel name to traverse. +- A stack round-trip already reaches its layers and propagates false/exception failure. Do not repeat those layer probes just to reproduce the wrapper's work. +- Probe the selected cache repository, including wrappers, and every failover child (primary included). Probe a child stack as a whole; traverse ordinary stack layers only to discover nested failovers. Deduplicate supplemental probes by name within the check. A false write, wrong read, or thrown probe/cleanup error fails. Successful writes/reads with unhealthy failover children warn, including when wrapper cleanup returns false; explain that cleanup could not be confirmed and list child failures separately, without claiming they were its only cause. Cleanup false without unhealthy failover children fails. Queue failover retains all-healthy/pass, partly-healthy/warn, all-unhealthy/fail verdicts using its non-writing probes. +- Judge async configuration by the first concrete child of a failover queue; fallback children are checked for reachability. Resolve the configured driver rather than treating the connection name as the driver. +- Normalize failover queue enum, null, and empty-string child names exactly as `QueueManager::connection()` does. Normalize enum names before emitting `QueueFailedOver`, preserving null's meaning. +- Probe SQS and Beanstalkd with their read-only `size()` operation: resolving a lazy queue is not a connection test. SQS requires `sqs:GetQueueAttributes`; preserve useful permission errors. In `BeanstalkdQueue`, centralize tube-stat retrieval and treat only `TubeNotFoundException` as an empty tube for size, pending, delayed, and reserved counts. Retain other failures. +- Include each active Redis cache store's effective lock connection (`lock_connection ?? connection`) as well as its data connection. Lock-only connections need reachability/base client support, not hash-field feature requirements. Deduplicate names within each check. Reachability and feature compatibility are separate diagnostics; do not add a global probe-result cache to couple them. + +Do not misreport an empty/malformed wrapper discovery as verified healthy. Let actual supported manager resolution expose invalid required configuration; avoid a second framework configuration validator. + +FailoverStore and FailoverQueue failure history must belong to the actual instance, not a recycled object ID. Store a weak owner and failure-list pair in the existing context entry; a missing or different owner means empty history. Preserve episode/recovery behavior, finally updates, and copied-child isolation without a shared registry or strong owner retention. + +Reject recursive built-in wrapper configurations at construction in their owning managers. Cache stack construction recurses immediately; lazy cache/queue failover cycles can exhaust process memory during an operation. Use local configuration walks, with no worker state or per-operation checks: + +- `CacheManager`: validate the actual supplied root record in `createStackDriver()` and `createFailoverDriver()`, following named children through `getConfig()`. Start with an empty named path: an on-demand build's label is not a named store identity. Stop at custom creators and non-wrapper records. Follow numeric and named stack entries exactly as the factory does. +- `QueueManager`: keep connector construction inside the existing construction callback. If its connector is a `FailoverConnector`, walk children whose driver matches that confirmed root driver. Do not construct child connectors for inspection; custom drivers own their configuration semantics and factory failures must remain lazy. +- Follow only usable wrapper child arrays; leave missing backups and other resolution failures deferred to normal failover. Track paths per branch, so shared children are valid. Throw `InvalidArgumentException` with the repeating chain and the existing Mail-style wording: `Circular cache store definition detected: a -> b -> a.` (or `queue connection`). +- Doctor's discovery helpers retain explicit path-local cycle errors because some consumers never resolve the selected manager. Cache reachability's supplemental failover discovery may stop at repeats: its actual root probe owns the failure. Remove discarded discovery calls from repair-candidate probes once manager validation owns this protection. + +### Cache round-trip and repair + +Resolve the chosen repository once. Use a unique short-lived probe key, require successful `put`, verify that `get` returns the value, and call `forget` in `finally`. Preserve cleanup's false return separately from thrown failures so the degraded failover rule above can apply; alternative-store repair candidates still require a completely successful probe. Preserve the primary exception if cleanup also fails; cancellation takes precedence and propagates. The `null` driver intentionally stores nothing: skip with an explicit explanation. Exclude null children from supplemental failover probes, while still requiring the selected wrapper's actual round-trip to succeed. The session cache driver supports an in-memory CLI round-trip and needs no bypass. + +Retain upstream's fixed `file`, `database`, and `redis` repair candidates, removing Memcached. A candidate must be configured and use a different driver from the failing store. Do not enumerate every configured store. Require the candidate probe to pass except for the supported database-table creation repair: + +- `make:cache-table` generates separate `cache` and `cache_locks` migrations using the default schema connection. It does not consume arbitrary cache table/connection settings. +- Creation is eligible only when both tables are missing, using the generated table names and effective default data/lock connections. Use the same predicate when presenting and executing the repair. A partially missing schema requires manual repair and must not be offered as healthy merely because cache-value operations succeed. A custom layout is still selectable if its existing tables and store pass the probes. +- Add `DatabaseStore::DEFAULT_LOCK_TABLE = 'cache_locks'`; use it in the constructor default, `CacheManager`, the generator, and Doctor. Add `CacheTableCommand::TABLE = 'cache'`: use it in both `handle()`'s table map and `migrationTableName()`, with `DEFAULT_LOCK_TABLE` for the other map key. Doctor compares against those actual generator constants. Keep `cache.stores.*.table` required, without a fallback. +- Keep shipped config values concrete/readable and test agreement with their owning constants. Treat omitted/null optional lock settings exactly as the manager does. +- The option label states that it creates cache tables and runs all pending migrations. An existing but unrun generated migration is valid: the generator warns and returns success, then `migrate --force` applies it. +- Recheck both tables and re-probe before updating the selected store. Do not claim success merely because generation/migration commands exited successfully. Persist and verify the selected default using the environment-repair behavior below. + +### Sessions and Redis feature requirements + +Add public `SessionManager::DEFAULT_REDIS_CONNECTION = 'session'` and use it in the factory and both Doctor consumers. Do not change database-session connection fallback semantics or interpret the string `session` as the database default. Read database-session and database-queue table names as required settings, matching `SessionManager` and `DatabaseConnector`; remove upstream's invented `sessions` and `jobs` fallbacks and misleading fallback names in errors. The `native` session alias uses the same file-path probe as `file`. + +Add `Hypervel\Redis\RedisRequirements` with owning constants: + +```php +public const string MINIMUM_PHPREDIS_VERSION = '6.1.0'; +public const string HASH_FIELD_EXPIRATION_PHPREDIS_VERSION = '6.3.0'; +public const string HASH_FIELD_EXPIRATION_REDIS_VERSION = '8.0.0'; +public const string HASH_FIELD_EXPIRATION_VALKEY_VERSION = '9.0.0'; +``` + +Expose two pure methods returning an unsupported reason or `null`: + +```php +public static function hashFieldExpirationClientError(string $version): ?string; +public static function hashFieldExpirationServerError(array $info): ?string; +``` + +Keep client detection and network access at callers. Preserve Valkey-version precedence over its Redis compatibility version. Missing server version means support could not be established. Copy the primary existing implementation before extracting it; remove replaced constants/logic from the original locations. + +Consumers: + +- `cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php`: base or feature client requirement, with existing presentation. +- `RedisVersionCheck.php`: server requirement with its existing separate verdict and command behavior. +- `HashFieldExpirationCheck.php`: remediation uses shared constants; retain its functional checks and stress behavior. +- `foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php`: keep detection overrides and memoization, reject old clients before fetching server info; unknown server versions no longer return true. +- New Doctor `Diagnostics/RedisSupportsConfiguredFeatures.php`, group `cache`, registered beside Redis reachability. + +The new diagnostic skips when no Redis connection is active. Otherwise check the base phpredis minimum. Hash-field minimums apply only to active Redis cache stores with effective `tag_mode = any` or Redis sessions with `session.track_user_sessions`. Query `INFO server` only for those feature-bearing connections, after client support passes, once per distinct connection. Ordinary Redis usage must not require Redis 8/Valkey 9/phpredis 6.3. An active Redis connection without those features still receives the base client result, not a skip. Cluster `info()` checks the default node; do not describe it as an audit of every cluster node. No changes to application request/job hot paths. + +## Paths, configuration, and environment + +Use application-owned path APIs: `config_path`, `storage_path`, `database_path`, and selected environment path/file. Avoid reconstructing them from `base_path`. + +- `ConfigurationFilesCanBeLoaded`: recursively inspect nested PHP config files as `LoadConfiguration` does; evaluate each file in a closure so its local variables cannot corrupt diagnostic state or failure attribution. Rethrow cancellation from both file evaluation and the enclosing diagnostic catch. Do not instantiate a second application or duplicate the loader. +- `ConfigurationCanBeCached`: validate `eval('return ' . var_export($configuration, true) . ';')` instead of writing and requiring a unique temporary file. This preserves `__set_state` validation while removing filesystem work and scripts retained by CLI OPcache across programmatic runs. Preserve detailed serialization errors; remove the obsolete temp-file helper, imports and cleanup. The framework's cache-building command already uses eval for per-value error diagnosis; its main validation runs in a fresh subprocess, so it does not need this worker-lifetime fix. +- Attribute serialization failures by evaluating flattened values, matching `ConfigCacheCommand`; do not reject every object or maintain an allowed-object list. Preserve the actual failure's key and cause. Pass through cancellation from export evaluation and failure attribution. +- `Configured`: generic getters are intentional where the diagnostic must recognize missing/malformed/union values. Use typed getters for genuinely fixed-type configuration, not to bypass a diagnostic's error reporting. +- `RequiredConfigurationValuesAreSet`: add active Mercure broadcaster requirements for `url`, effective publishing credentials (`publish_secret` or `secret`), and effective subscription credentials (`subscribe_secret` or `secret`). Match the factory's truthy fallback precedence rather than Postmark's non-null precedence. Do not instantiate a hub or duplicate its algorithm/key validation merely to check required values. +- Normalize mailer URLs through `ConfigurationUrlParser`, sharing the normalized record between active-mailer traversal and required-value checks. Match `MailManager`: Postmark/Resend use mailer `key` then service `key`, never legacy `token`; Cloudflare selects `account_id` and `key` per field; Mailgun selects the whole mailer record when `secret` is set, otherwise the service record. Match LogManager's merge of `with` then `handler_with`, including null overrides. Omitted/null SQS overflow store means the default cache store, not missing configuration. +- `StorageIsWritable`: include `storage/framework` itself, the standard required storage directories, and deduplicated parent directories of `getCachedConfigPath`, `getCachedRoutesPath`, `getCachedEventsPath`, and `getCachedPackagesPath`. Honor relocated configured session/view/file-cache paths where those services are active. Display actual paths. In `fix()`, recompute failures and create/chmod only failing paths, then recheck; leave healthy directories' permissions unchanged. In particular, a configured shared directory such as `/tmp` must not have its sticky bit removed by a repair for another path. +- Include the effective file-cache lock directory as well as its data directory. For existing failing directories, add owner access while preserving other permission and special bits; use normal creation permissions for new directories. +- `BootstrapCacheMatchesEnvironment`: report config, routes and events separately. Remove all compiled-view scanning; on-demand compiled views prove neither deployment optimization nor stale bootstrap config. Production: warn when none are cached; report a partial cache as a notice identifying cached/uncached types; pass when all are cached. Local: pass when none are cached, otherwise notice naming cached types. Missing optional optimizations are advice, not an application failure. Keep one upstream diagnostic/result rather than inventing nested diagnostic results. +- `EnvironmentFileExists`: inspect exactly `environmentFilePath()`, never `.env*`. Fix that selected file, using `.env.example` under `environmentPath()` when available. Never overwrite an existing selected file. In production, missing file skips; existing files are still checked by applicable diagnostics. +- `PublicStorageLinkExists`: use the configured public disk root and matching configured links as described below. Treat `.gitkeep` and `.gitignore` as placeholders; do not alter Testbench's skeleton or delete its placeholder to make tests pass. +- Retain configured filesystem links, disk probes, and local repair restrictions. Tenant-driver exceptions remain errors when a caller deliberately selects a tenant resource without context. + +### Persistent environment repairs + +Application-key generation, debug-mode repair, and cache-default selection must verify the setting a fresh application actually reads. Share this logic within Doctor, without changing the fix callback APIs or introducing a general repair framework. + +- Select the environment filename in `LoadEnvironmentVariables` before returning for cached configuration; keep dotenv loading skipped in cached mode. This makes all environment-file consumers use the selected file. +- Add read-only `DotenvManager::getLoadedVariableNames(): array`, using existing loaded-key tracking. Doctor's configuration rebuild and verification subprocesses exclude those inherited variables through Process's `false` environment values; genuinely external values remain. Forward explicit `--env` from process argv and pass the application's resolved `APP_CONFIG_CACHE` after exclusions. Share one Process setup for both operations; do not reload or mutate the parent's environment. +- Preserve normal environment inheritance in Foundation's configuration and route cache builders. Forward explicit `--env` from process argv into both build children, including nested Artisan calls; retain their existing cache-path and autoloader overrides. +- Add `config:show --json` for a section or individual key, preserving JSON value types and existing missing-key behavior. Normal output is unchanged. +- After writing the selected environment file, run `config:cache` in a clean child only if its cache file currently exists, then read the exact key through a clean `config:show --json` child. This also supports maintenance commands that build a cache after an uncached boot and then invoke Doctor programmatically. `configurationIsCached()` records this application's boot state and must not decide whether a maintenance operation rebuilds the current file. A failed command or mismatched value fails the repair. Update current config from the verified value, including restoring a mismatched fresh app key after `key:generate` changed memory. Never print secret values in failure details. +- Recheck an existing application key before generating, so stale fix results cannot rotate a key. Extend the owning `KeyGenerateCommand` replacement pattern to handle exported assignments while preserving the protected hook and matching the current key. Accept and preserve trailing whitespace and comments ignored by dotenv without matching a different or longer key. +- Document `server:reload` or restart after deployment-setting changes; do not restart workers automatically. +- At worker start, `ReloadDotenvAndConfig` skips dotenv reload when the configuration cache file currently exists, and loads dotenv when that file has been removed. Preserve the memoized application API and existing config rebuild/replay. Do not flush inherited dotenv values: that would change cache-path resolution when `APP_CONFIG_CACHE` came from the file. Document that, when an uncached master switches to cached workers, a full restart removes inherited dotenv values visible through runtime `env()` calls; cached configuration itself takes effect on worker reload. + +Composer autoload inspection uses `dump-autoload --dry-run --no-scripts`, since dry-run alone still executes pre-autoload scripts. Keep scripts enabled for the explicit repair. Composer lock and autoload repairs run without a process timeout, matching the framework's Composer commands; inspection processes retain their existing bounds. + +Composer audit distinguishes vulnerable packages, completed reports that fail configured abandoned/filter policies, and failures to run the audit. Recognize completed JSON reports by their advisories array; let Composer's exit status decide policy failure rather than reimplementing its policy. Exit-zero report-only findings pass. Include useful package/replacement or filter-list details without calling every reported match a blocking finding. + +### Subprocess inheritance and server bootstrap + +Doctor's clean-environment policy belongs only to its persistent-setting repairs. Keep normal inheritance for Foundation's cache builders, queue listeners, Horizon, Concurrency, development commands, watcher commands, scheduler processes, and Composer. Preserve Horizon's and the watcher's intentional parent dotenv reloads. Their children and custom commands may depend on inherited values; Doctor's independent-launch verification does not establish a general subprocess policy. + +Remove the `runningInConsole()` argv guards from both `LoadEnvironmentVariables` and `Application::detectEnvironment()`. This architectural adaptation is approved: server-mode Artisan commands receive real process argv, and their explicit `--env` selects both the file and application environment. Preserve `EnvironmentDetector`'s protected parsing hooks and all other console/HTTP mode behavior. Update the tests that previously required ignoring argv and record this deliberate public behavior in Foundation's Laravel differences. + +The framework-owned `APP_RUNNING_IN_CONSOLE=false` flag describes the current process, so server entrypoints set it only in `$_SERVER`. Swoole workers inherit it through forking; separately executed Artisan children must determine their own mode. Update Testbench's runtime skeleton, Commander, and matching integration server fixtures. Extend context-propagation coverage through the actual Concurrency process driver with a server-mode parent. Apply the matching two-line removal to the separate `hypervel/hypervel` skeleton's `artisan`; that repository correction is authorized. + +### Public storage links + +The diagnostic and its repair must inspect the same configuration. Skip a non-local public disk with the existing `not-default-local-path` code and a message describing the local-disk requirement. For a local disk, read its configured `root` and select the `filesystems.links` entries whose normalized target equals that root. Reuse upstream's realpath-or-trailing-separator normalization; no hardcoded public link or storage root is needed. If no configured links target that disk, skip with a new `not-configured` code. Inspect the configured root lazily, stopping at the first entry other than `.gitignore`, `.gitkeep`, or dot entries; do not list an entire uploads directory to establish usage. + +Evaluate each expected link in this order: + +| State | Result | +|---|---| +| `realpath($link)` equals the normalized target | Valid, including relative links and symlink chains. | +| Neither `file_exists($link)` nor `is_link($link)` | Missing; eligible for the existing repair. | +| Existing symlink that is dangling or resolves elsewhere | Failure; no automatic repair. Explain explicit `storage:link --force` remediation. | +| Other conflicting file/directory | Failure; no automatic repair. Explain moving the conflict aside before `storage:link`; never remove it. | + +Keep one diagnostic result with path-specific details. Mark it fixable only when every failing expected link is missing. Preserve `linked`, `missing`, `unused`, `created`, and `creation-failed`; add distinct message codes for wrong symlinks and conflicting paths. Do not add per-link result objects, inode/bind-mount detection, or a generic link-management layer. + +Run plain `storage:link` through `Artisan::call()` against the inspected application's configuration, without `--force` or `--relative`, then evaluate the expected links again using the same inspection routine. Return `created` only when the command succeeds and every expected link resolves correctly. Otherwise return `creation-failed` with useful path details and command output. Rethrow cancellation and convert other command exceptions into that failed-fix result. A zero exit status alone is insufficient: `StorageLinkCommand` intentionally reports existing-path conflicts while remaining successful for idempotent deployment calls. Preserve those conflict semantics, including the explicit `--force` requirement for dangling symlinks. Remove the stale literal `filesystems.links` fallbacks from both storage link commands; shipped configuration owns that required setting. Forcing the command would also replace other configured links beyond this diagnostic's scope. + +### Git tracking and ignoring + +Use argument-array Process commands with bounded execution, the selected environment file's directory as working directory, and `LC_ALL=C`. Preserve inherited ignore rules and application subdirectories within a larger repository. + +1. Establish applicable work-tree membership with `git rev-parse --is-inside-work-tree`. A missing executable surfaces from the array Process command as exit 127, not an exception; report the missing-Git skip. Recognize “not a repository” only from exit 128 plus the C-locale `not a git repository` stderr message, with its own skip explanation. Exit 128 alone also covers ownership/permission failures; those remain diagnostic errors. A false work-tree result is not a work tree. +2. `git --literal-pathspecs ls-files --error-unmatch -- `: success means tracked and therefore unfixable failure; explain manual `git rm --cached` remediation. Literal pathspecs prevent special characters in the selected filename from matching other files. Apply this flag only to `ls-files`, never through shared `GIT_LITERAL_PATHSPECS`, which `check-ignore` rejects. Exit 1 means untracked; other failures remain errors. +3. `git check-ignore --no-index -q -- `: success passes, exit 1 offers the local ignore repair, other failures are errors. The preceding literal index check owns tracking detection; `--no-index` prevents this command's separate index/pathspec matching from treating a wildcard filename as a different tracked file. +4. Repair only within the applicable work tree, in `.gitignore` beside the selected environment file. Append an anchored, correctly escaped literal basename, retaining existing content/newline handling. A rule in the application directory cannot ignore a file outside it. Do not create ignore rules in unrelated repositories or untrack files. +5. Recheck effective ignoring after repair. Report failure if the selected file is still unprotected. Use the selected path in all messages. + +## Platform requirements and one source of truth + +Startup guards already run before command dispatch (`Application::checkEnvironment`, foundation timezone boot). Do not add a startup bypass or duplicate unreachable extension/build-flag checks. The Swoole minimum belongs to installed Composer requirements, not a new Doctor or foundation version constant. + +Create non-final, stateless `Support/InstalledPackages`, container-resolved like `ComposerJson`. One call per diagnostic produces a fresh snapshot: + +```php +/** + * @return array, + * providers: array + * }> + */ +public function platformRequirements(ComposerJson $composer): array; +``` + +Pass each diagnostic's existing injected `ComposerJson` to the snapshot helper, preserving its constructor and supplied root metadata. Read installed.json once per call, not once per extension/provider. Resolve `Hypervel\Foundation\PackageManifest` and use its public `vendorPath`, sharing the framework's `COMPOSER_VENDOR_DIR`/application-vendor rule and Testbench binding. Do not scan Composer loaders or add a second vendor-directory policy. A protected `vendorPath()` supports focused fixture substitution without a public testing-only API. Declare Doctor's direct Foundation dependency. Read root version metadata from that directory's `composer/installed.php` when needed for `self.version`. + +Build the snapshot from: + +- Root composer.json `require`, even if matching installed metadata is unavailable. +- Root `require-dev` only when installed metadata positively records dev mode. +- Each installed package's `require`, never its `require-dev`. +- Root and installed package `provide`/`replace` links for relevant platform packages. Resolve `self.version` using that package's available version; root installed metadata supplies its version. Do not turn an unknown root self-version into an invented constraint. + +Missing installed metadata removes only the installed contribution. It does not suppress available root checks. Keep the existing public `ComposerJson` methods. Recommended extensions remain root suggestions rather than interpreting every dependency suggestion as a framework requirement. Both extension diagnostics use `InstalledPackages::loadedExtensions()` for the map of Composer-normalized names to runtime versions; do not cache it or duplicate normalization. + +For required extensions, follow Composer's platform normalization: + +- Map loaded names to `ext-` plus lowercase name with spaces replaced by hyphens; omit synthetic `Core`/`standard` entries. +- Use current-process `phpversion($extension) ?: '0'`. Normalize via `VersionParser`; if necessary use the leading numeric three/four-component version, else `0`, matching Composer's fallback. +- A real loaded version is one candidate; each provider/replacement is another. Compare real versions with `Semver::satisfies()`. Provider/replacement links are ranges, not versions: parse both link and requirement with `VersionParser::parseConstraints()` and compare with `matches()`, as Composer does. Resolve `self.version` to the providing package's exact version constraint. One candidate must match every requiring constraint for that platform package. Do not demand a physical extension when a valid Composer provider satisfies the graph. +- Report the loaded version or absence, requiring package names, and unmet constraints. Do not compare against duplicated Swoole literals. + +`PhpVersionSatisfiesComposerRequirement` uses the same snapshot for all `php` constraints, including upper bounds. Check actual `PHP_VERSION`, not `config.platform` emulation. Normalize once using Composer's version parser; only if normalization fails, strip the distribution suffix at the first `~`, `+`, or `-` and normalize again. Preserve valid prereleases and display the original version. Skip only if no applicable PHP requirement exists. Preserve message codes but update summaries/details to name requiring packages and constraints. Recommend a compatible PHP or dependency version; do not tell users to edit the root constraint to resolve a dependency's requirement. No Composer subprocess or dependency solver is needed. + +Fresh metadata matters: `ComposerLockIsFresh` can install/update packages, and the post-fix check must see that new graph even when root composer.json is unchanged. Do not add a worker cache plus invalidation mechanism for this small per-run read. + +## Extensibility and tenancy + +Keep the upstream extension model: subclass `Diagnostic`, implement `check(): DiagnosticResult`, optionally implement `Fixable`, register classes through `Doctor::diagnostic()`/`diagnostics()` during provider boot, and select by class/group/package. Keep `make:diagnostic`, its fixable stub, message/remediation/link APIs and programmatic runs. No new plugin registry or tenancy hook is required. + +Bundled diagnostics interpret built-in driver names using their standard semantics, as Laravel Doctor does. Applications replacing those semantics can select custom diagnostics instead. Mention this briefly beside custom diagnostics; do not add manager introspection APIs or a Laravel-differences entry for it. + +Doctor checks selected application resources. A tenant-scoped driver that requires tenant context must retain that guard; do not swallow its exception or automatically iterate tenants. Applications and packages can register specialized diagnostics through the existing extension API. No framework tenancy hooks are needed for this port. + +## Testing and verification + +Port upstream tests into `tests/Doctor/`, preserving placement and converting Pest to PHPUnit with the repository base classes. Keep the shared provider/environment setup and restore every changed process environment variable. Preserve all applicable upstream coverage; the timezone exclusion is approved. + +After each test file is created/changed, run that file immediately with a suitable command timeout. Use `Hypervel\Tests\TestCase` for units and `Hypervel\Testbench\TestCase` for app/container/file writes. Configure app setup in `defineEnvironment`. Use Testbench's disposable app and `ParallelTesting::tempDir`; restore files, selected env paths, environment variables and process state. Never write into the committed skeleton. + +Keep all applicable upstream coverage. Add regressions to the corresponding upstream test file where possible; only new helpers/diagnostics need new files. Use data providers for meaningful variants, not one near-identical test per I/O catch. + +| Area | Required behavioral coverage | +|---|---| +| Core/CLI | Fresh diagnostic on rerun; original callback/result semantics; cancellation identity from check/fix plus a representative probe; interactive `--fix` never prompts for a choice; normal interactive choices still work; JSON/GitHub formats and exit statuses; generated strict-typed stubs. | +| Source attribution | Actual root resolves to `hypervel/components`; a real vendor class resolves to the deeper vendor package path. Do not mutate global InstalledVersions, test the test-cleanup hook itself, or invent split-package attribution in the development root. | +| Database | Aliased and URL-configured drivers; configured read/write suffixes; healthy split and unreachable read records with record-owned options; extension resolution and untouched custom options; invalid built-in options; cleanup after success/failure; effective SQLite role paths and memory/URI/file repair distinction. | +| Migrations | Existing routing tests remain intact. `tests/Database/MigratorCoroutineIsolationTest.php` covers overlapping selected connections, independent restoration, output isolation, fork inheritance without parent mutation, independent live and released/replacement instances, and non-coroutine behavior. Doctor checks the configured migrations target and restores ambient context. | +| Cache/wrappers | False put, mismatched read, plain cleanup failure and preserved primary cancellation/error; null skip; session round-trip; stack named/numeric entries; failover healthy/degraded/failed; dead primary/backup and nested failover with successful write/read but false cleanup warn; false-writing primary fails while a false-writing backup warns; nested wrappers and cycles; duplicate names produce useful unduplicated diagnostics; eligible/ineligible creation repair and honest label; already-generated pending migrations; re-probe before switching. | +| Manager cycles | Extend existing CacheManagerTest/QueueManagerTest: self/indirect cycles reject with their chain; valid nested/shared children work; cache on-demand labels do not create false cycles; custom creators/connectors remain authoritative; missing backups and throwing custom queue connector factories stay lazy. Preserve existing pooled connector behavior. | +| Redis/session | Correct session fallback; missing required database session/queue tables fail; active Redis data/lock names are included and inactive stores excluded; base client and conditional feature minimums; Redis versus Valkey precedence; unknown server version; no INFO required for lock-only/inactive features or unsupported clients. Existing manual Redis checks retain their command behavior. Extend existing trait/check tests and add focused RedisRequirements/Doctor tests. | +| Files/config | Custom storage/config/database/bootstrap/session/view roots; repairing a failing directory preserves healthy directory modes; nested config failures with filenames; config export round-trip and detailed serialization errors, including `__set_state`; Mercure effective publishing/subscription credential alternatives; partial bootstrap states; no view-directory scanning; exact selected env file and production omission. | +| Public storage links | Extend the upstream test file: data providers for `.gitignore`/`.gitkeep` placeholders and wrong/dangling links; preserve conflicting real files/directories; configured nonstandard links and custom public roots pass and repair; relative links pass; mixed missing/conflicting links are not fixable; zero-exit repair without a correct link fails. Keep source-command conflict semantics intact. | +| Git | Actual isolated repositories: tracked even if matching an ignore rule; ancestor ignore rules; negation; selected filename/path; untracked repair and effective recheck; no repository/missing Git/real process error distinctions, including missing executable exit 127 and fatal safe-directory ownership errors. Use Process fakes for command-error branches. | +| Platform | Root-only operation, installed dependencies, dev versus no-dev, exclusion of dependency require-dev, providers/replacements including self.version, actual version constraints and normalization, PHP upper bounds, missing metadata, nondefault vendor path, and changed installed metadata on the post-fix pass. Use fixture installed.json/installed.php through protected vendorPath override. | +| Shared constants | Constructor/factory/generator/config agree with the owning cache/session defaults. Extend existing behavioral tests rather than asserting every literal at every caller. | + +Inspect existing coverage before adding files. Do not retest `makeTransient` binding semantics already covered by container tests, add speculative production test seams, or assert incidental helper call counts. Network-call avoidance is verified through inactive/unsupported cases that must succeed without a server, not by mirroring the implementation's loop counts. + +Add released/replacement-instance regressions to the existing cache and queue failover tests. Composer audit cases cover abandoned/filter policy failures, failed execution, report-only success, and a missing lock. Remove ineffective subprocess-autoloader environment setup and redundant fixture path resets; keep explicit environment overrides and the piped-input subprocess's actual autoloader setting. + +Audit regressions cover literal markup and backslashes through actual machine-output commands, config files declaring local variables before failure, distro PHP versions and valid prereleases, unlimited Composer repair timeouts through existing process assertions, and trailing dotenv key comments/whitespace through the existing key-generation provider. Keep documentation output examples consistent with the renderer and diagnostic messages. + +Correction coverage must exercise real boundaries: fresh application boots for repaired settings in cached/uncached and explicit-environment modes; external overrides and config that ignores the written variable; actual cache migration generation against SQLite, including partially missing and recorded-but-dropped tables; supported mail factory precedence and URLs; read-only remote queue operations and absent Beanstalkd tubes; enum/default wrapper names and failover events; piped CLI input and numeric option keys; exported/stale app keys; SQLite creation races; preserved directory modes; nested cancellation and actual serialization-error attribution. Correct test expectations that contradict owning factories instead of preserving copied upstream assumptions. + +Use real commands for Doctor's migration and storage-link repairs on disposable SQLite tables and configured links; retain failure/output/conflict coverage. Extend the existing repair data provider to cover dotenv loaded before a sequential cache build and programmatic repair, retaining external-override cases. Extend the real route-cache child tests for explicit environment selection and the process-driver test for context propagation from an HTTP-mode parent. Cover both environment-file selection and application-environment detection with server mode enabled, updating the two tests that explicitly required ignoring argv. Preserve existing launcher-inheritance and reload coverage. + +Use fakes for ordinary external-service failure paths. If an integration test needs a real service, put it in the appropriate `tests/Integration/...` directory, use its service isolation trait, and update CI discovery only if a new directory requires it. No benchmark without fresh owner confirmation that the machine is idle. + +Final checks run sequentially from the worktree root: + +1. Review the diff against upstream inventory and this plan. Check declared dependencies against final imports, public/protected API parity, named arguments, extension hooks, and every approved omission. Check all touched code for stale comments, old framework names, dead branches, obsolete TODOs, unused imports and redundant tests. +2. Run focused affected suites after coherent changes. Use ParaTest for multiple files and a process count supported by configured Redis database isolation. +3. Run targeted Doctor source PHPStan after porting, resolving actual type problems without blanket ignores. Tests are not PHPStan targets. +4. Because this design touches database/cache/session/foundation/testing behavior, run `composer fix` for final formatting, analysis and full test verification. Do not run its full component checks redundantly beforehand. Bound execution and keep verbose logs in a task-specific temporary directory. +5. Correct failures at their owner, rerun the failed check and remaining checks, then request independent code review with results. Review performance/scalability and Laravel ergonomics explicitly: no redundant I/O, unbounded worker caches, new runtime hot-path work, or generic machinery without a concrete need. +6. Remove disposable investigation files, preserve the durable worktree, and report completion plus any approval-dependent remaining work accurately. Commit only with explicit authorization. diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index bdbd2d1970..6c3e9aca47 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -215,7 +215,7 @@ protected function createDatabaseDriver(array $config): Repository $config['connection'] ?? null, $config['table'], $this->getPrefix($config), - $config['lock_table'] ?? 'cache_locks', + $config['lock_table'] ?? DatabaseStore::DEFAULT_LOCK_TABLE, $config['lock_lottery'] ?? [2, 100], $config['lock_timeout'] ?? 86400, serializableClassPolicy: $this->serializableClassPolicy, @@ -235,6 +235,8 @@ protected function createDatabaseDriver(array $config): Repository */ protected function createFailoverDriver(array $config): Repository { + $this->ensureStoresAreAcyclic($config); + return $this->repository(new FailoverStore( $this, $this->app->make(DispatcherContract::class), @@ -357,6 +359,8 @@ protected function createSwooleDriver(array $config): Repository */ protected function createStackDriver(array $config): Repository { + $this->ensureStoresAreAcyclic($config); + $stores = collect($config['stores'])->map(function ($config, $name) { if (! is_array($config)) { $name = $config; @@ -371,6 +375,44 @@ protected function createStackDriver(array $config): Repository return $this->repository(new StackStore($stores), $config); } + /** + * Reject circular built-in store definitions before resolving their layers. + * + * @param list $path + */ + protected function ensureStoresAreAcyclic(array $config, array $path = []): void + { + $driver = $config['driver'] ?? null; + + if (! in_array($driver, ['stack', 'failover'], true) + || isset($this->customCreators[$driver]) + || ! is_array($config['stores'] ?? null)) { + return; + } + + foreach ($config['stores'] as $name => $options) { + $name = $driver === 'stack' && is_array($options) ? $name : $options; + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + $name ??= $this->getDefaultDriver(); + + if (! is_string($name)) { + continue; + } + + if (($cycleStart = array_search($name, $path, true)) !== false) { + $cycle = [...array_slice($path, $cycleStart), $name]; + + throw new InvalidArgumentException( + 'Circular cache store definition detected: ' . implode(' -> ', $cycle) . '.' + ); + } + + if (($child = $this->getConfig($name)) !== null) { + $this->ensureStoresAreAcyclic($child, [...$path, $name]); + } + } + } + /** * Create a new cache repository with the given implementation. */ diff --git a/src/cache/src/Console/CacheTableCommand.php b/src/cache/src/Console/CacheTableCommand.php index e7c6993156..37d3b88c51 100644 --- a/src/cache/src/Console/CacheTableCommand.php +++ b/src/cache/src/Console/CacheTableCommand.php @@ -4,12 +4,15 @@ namespace Hypervel\Cache\Console; +use Hypervel\Cache\DatabaseStore; use Hypervel\Console\MigrationGeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'make:cache-table', aliases: ['cache:table'])] class CacheTableCommand extends MigrationGeneratorCommand { + public const string TABLE = 'cache'; + /** * The name and signature of the console command. */ @@ -33,8 +36,8 @@ class CacheTableCommand extends MigrationGeneratorCommand public function handle(): int { $tables = [ - 'cache' => __DIR__ . '/stubs/cache.stub', - 'cache_locks' => __DIR__ . '/stubs/cache-locks.stub', + self::TABLE => __DIR__ . '/stubs/cache.stub', + DatabaseStore::DEFAULT_LOCK_TABLE => __DIR__ . '/stubs/cache-locks.stub', ]; foreach ($tables as $table => $stub) { @@ -56,7 +59,7 @@ public function handle(): int */ protected function migrationTableName(): string { - return 'cache'; + return self::TABLE; } /** diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index 85f8bcf8a8..5cb4a8aa12 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -23,6 +23,8 @@ class DatabaseStore implements CanFlushLocks, LockProvider, Store { use InteractsWithTime; + public const string DEFAULT_LOCK_TABLE = 'cache_locks'; + /** * The database connection resolver. */ @@ -81,7 +83,7 @@ public function __construct( ?string $connectionName, string $table, string $prefix = '', - string $lockTable = 'cache_locks', + string $lockTable = self::DEFAULT_LOCK_TABLE, array $lockLottery = [2, 100], int $defaultLockTimeoutInSeconds = 86400, array|bool|null $serializableClasses = null, diff --git a/src/cache/src/FailoverStore.php b/src/cache/src/FailoverStore.php index cfc150b8b6..fe98c420d5 100644 --- a/src/cache/src/FailoverStore.php +++ b/src/cache/src/FailoverStore.php @@ -19,6 +19,7 @@ use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; +use WeakReference; use function Hypervel\Support\enum_value; @@ -27,8 +28,8 @@ class FailoverStore extends TaggableStore implements AuthoritativeRawReadable, C /** * Context key prefix for the caches which failed on the last action. * - * Scoped per instance via spl_object_id() so multiple failover stores - * in the same coroutine don't share failure history. + * Entries pair a weak owner with its failure list. A dead or different owner + * means no history, since released object IDs may be reused within a coroutine. * * Stored in coroutine Context instead of an instance property because this * store is a worker-lifetime singleton (cached in CacheManager::$stores). @@ -348,8 +349,7 @@ public function getPrefix(): string protected function attemptOnAllStores(Closure|string $method, array $arguments = []): mixed { $contextKey = self::FAILING_CACHES_CONTEXT_PREFIX . spl_object_id($this); - - $failingCaches = CoroutineContext::get($contextKey, []); + $failingCaches = $this->failingCaches($contextKey); [$lastException, $failedCaches] = [null, []]; @@ -382,7 +382,7 @@ protected function attemptOnAllStores(Closure|string $method, array $arguments = } } } finally { - CoroutineContext::set($contextKey, $failedCaches); + $this->rememberFailingCaches($contextKey, $failedCaches); } throw $lastException ?? new RuntimeException('All failover cache stores failed.'); @@ -396,7 +396,7 @@ protected function attemptOnAllStores(Closure|string $method, array $arguments = protected function attemptOnEveryStore(string $method, array $arguments): array { $contextKey = self::FAILING_CACHES_CONTEXT_PREFIX . spl_object_id($this); - $failingCaches = CoroutineContext::get($contextKey, []); + $failingCaches = $this->failingCaches($contextKey); $results = []; $lastException = null; $failedCaches = []; @@ -425,12 +425,34 @@ protected function attemptOnEveryStore(string $method, array $arguments): array } } } finally { - CoroutineContext::set($contextKey, $failedCaches); + $this->rememberFailingCaches($contextKey, $failedCaches); } return [$results, $lastException]; } + /** + * Get failure history belonging to this store instance. + * + * @return list + */ + private function failingCaches(string $contextKey): array + { + $state = CoroutineContext::get($contextKey); + + return $state !== null && $state[0]->get() === $this ? $state[1] : []; + } + + /** + * Remember the stores that failed during the current operation. + * + * @param list $failedCaches + */ + private function rememberFailingCaches(string $contextKey, array $failedCaches): void + { + CoroutineContext::set($contextKey, [WeakReference::create($this), $failedCaches]); + } + /** * Build failure history after an interrupted failover attempt. * diff --git a/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php index 66fd5871f0..a1376de074 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/HashFieldExpirationCheck.php @@ -6,6 +6,7 @@ use Hypervel\Cache\Redis\Console\Doctor\CheckResult; use Hypervel\Redis\RedisConnection; +use Hypervel\Redis\RedisRequirements; use Throwable; /** @@ -82,7 +83,9 @@ public function getFixInstructions(): ?string } if (! $this->available) { - return 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.'; + return 'Any tagging mode requires Redis ' . RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION + . '+ or Valkey ' . RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION + . '+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.'; } return null; diff --git a/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php index 311ec05d70..b13b398f05 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php @@ -5,16 +5,13 @@ namespace Hypervel\Cache\Redis\Console\Doctor\Checks; use Hypervel\Cache\Redis\Console\Doctor\CheckResult; +use Hypervel\Redis\RedisRequirements; /** * Checks that PHPRedis is installed with the version required by the tag mode. */ final class PhpRedisCheck implements EnvironmentCheckInterface { - private const string MINIMUM_VERSION = '6.1.0'; - - private const string ANY_TAG_MINIMUM_VERSION = '6.3.0'; - private ?string $installedVersion = null; /** @@ -84,7 +81,7 @@ public function getFixInstructions(): ?string private function requiredVersion(): string { return $this->taggingMode === 'any' - ? self::ANY_TAG_MINIMUM_VERSION - : self::MINIMUM_VERSION; + ? RedisRequirements::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION + : RedisRequirements::MINIMUM_PHPREDIS_VERSION; } } diff --git a/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php index d11811591b..16dc81e2b2 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php @@ -6,21 +6,17 @@ use Hypervel\Cache\Redis\Console\Doctor\CheckResult; use Hypervel\Redis\RedisConnection; +use Hypervel\Redis\RedisRequirements; use Throwable; /** * Checks that Redis/Valkey version meets requirements. * - * For any mode: Requires Redis 8.0+ or Valkey 9.0+ for hash-field - * expiration commands including HSETEX and HEXPIRE. + * For any mode: Requires hash-field expiration commands including HSETEX and HEXPIRE. * For all mode: Any version is acceptable (just verifies connection). */ final class RedisVersionCheck implements EnvironmentCheckInterface { - private const string REDIS_REQUIRED_VERSION = '8.0.0'; - - private const string VALKEY_REQUIRED_VERSION = '9.0.0'; - private ?string $serviceName = null; private ?string $serviceVersion = null; @@ -57,11 +53,11 @@ public function run(): CheckResult if (isset($info['valkey_version'])) { $this->serviceName = 'Valkey'; $this->serviceVersion = $info['valkey_version']; - $requiredVersion = self::VALKEY_REQUIRED_VERSION; + $requiredVersion = RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION; } elseif (isset($info['redis_version'])) { $this->serviceName = 'Redis'; $this->serviceVersion = $info['redis_version']; - $requiredVersion = self::REDIS_REQUIRED_VERSION; + $requiredVersion = RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION; } else { $result->assert(false, 'Could not determine Redis/Valkey version'); @@ -72,7 +68,7 @@ public function run(): CheckResult // Version requirement only applies to any mode if ($this->taggingMode === 'any') { - $versionIsSupported = version_compare($this->serviceVersion, $requiredVersion, '>='); + $versionIsSupported = RedisRequirements::hashFieldExpirationServerError($info) === null; $result->assert( $versionIsSupported, "{$this->serviceName} version >= {$requiredVersion} (required for any tagging mode)" @@ -105,14 +101,14 @@ public function getFixInstructions(): ?string } if ($this->serviceName === 'Redis' && $this->serviceVersion !== null) { - if (version_compare($this->serviceVersion, self::REDIS_REQUIRED_VERSION, '<')) { - return 'Upgrade to Redis ' . self::REDIS_REQUIRED_VERSION . '+ or Valkey ' . self::VALKEY_REQUIRED_VERSION . '+ for any tagging mode'; + if (version_compare($this->serviceVersion, RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION, '<')) { + return 'Upgrade to Redis ' . RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION . '+ or Valkey ' . RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION . '+ for any tagging mode'; } } if ($this->serviceName === 'Valkey' && $this->serviceVersion !== null) { - if (version_compare($this->serviceVersion, self::VALKEY_REQUIRED_VERSION, '<')) { - return 'Upgrade to Valkey ' . self::VALKEY_REQUIRED_VERSION . '+ for any tagging mode'; + if (version_compare($this->serviceVersion, RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION, '<')) { + return 'Upgrade to Valkey ' . RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION . '+ for any tagging mode'; } } diff --git a/src/console/src/Application.php b/src/console/src/Application.php index 3fafe813ef..1a3ef3cd91 100644 --- a/src/console/src/Application.php +++ b/src/console/src/Application.php @@ -226,6 +226,7 @@ public static function forgetBootstrappers(): void * Run an Artisan console command by name. * * @throws CommandNotFoundException + * @throws Throwable when command execution fails */ public function call(string|SymfonyCommand $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int { diff --git a/src/contracts/src/Console/Application.php b/src/contracts/src/Console/Application.php index fe46002930..08f0cc590d 100644 --- a/src/contracts/src/Console/Application.php +++ b/src/contracts/src/Console/Application.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; interface Application { @@ -48,6 +49,7 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu * Run an Artisan console command by name. * * @throws CommandNotFoundException + * @throws Throwable when command execution fails */ public function call(string|SymfonyCommand $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int; diff --git a/src/contracts/src/Console/Kernel.php b/src/contracts/src/Console/Kernel.php index af641bddf6..244aa62c46 100644 --- a/src/contracts/src/Console/Kernel.php +++ b/src/contracts/src/Console/Kernel.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; interface Kernel { @@ -65,6 +66,7 @@ public function registerCommand(SymfonyCommand $command): void; * Run an Artisan console command by name. * * @throws CommandNotFoundException + * @throws Throwable when application bootstrap or command execution fails */ public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int; diff --git a/src/database/src/Migrations/DatabaseMigrationRepository.php b/src/database/src/Migrations/DatabaseMigrationRepository.php index 80457f3cd5..6f2ef7789b 100755 --- a/src/database/src/Migrations/DatabaseMigrationRepository.php +++ b/src/database/src/Migrations/DatabaseMigrationRepository.php @@ -4,16 +4,43 @@ namespace Hypervel\Database\Migrations; +use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface as Resolver; use Hypervel\Database\Query\Builder; +use WeakReference; class DatabaseMigrationRepository implements MigrationRepositoryInterface { + protected const string CONNECTION_CONTEXT_KEY_PREFIX = '__database.migration_repository.connection.'; + /** * The name of the database connection to use. */ - protected ?string $connection = null; + protected ?string $connection { + get { + if (! Coroutine::inCoroutine()) { + return $this->nonCoroutineConnection; + } + + $state = CoroutineContext::get(self::CONNECTION_CONTEXT_KEY_PREFIX . spl_object_id($this)); + + // A released object's ID may be reused while its context entry survives. + return $state !== null && $state[0]->get() === $this ? $state[1] : null; + } + set { + if (Coroutine::inCoroutine()) { + CoroutineContext::set(self::CONNECTION_CONTEXT_KEY_PREFIX . spl_object_id($this), [ + WeakReference::create($this), $value, + ]); + } else { + $this->nonCoroutineConnection = $value; + } + } + } + + private ?string $nonCoroutineConnection = null; /** * Create a new database migration repository instance. diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 89f6a22619..fce0e939d9 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -14,6 +14,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Database\Events\MigrationEvent as MigrationEventContract; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\ConnectionResolverInterface as Resolver; @@ -30,9 +31,12 @@ use Hypervel\Support\Str; use InvalidArgumentException; use ReflectionClass; +use WeakReference; class Migrator { + protected const string STATE_CONTEXT_KEY_PREFIX = '__database.migrator.'; + /** * The custom connection resolver callback. * @@ -43,7 +47,12 @@ class Migrator /** * The name of the default connection. */ - protected ?string $connection = null; + protected ?string $connection { + get => $this->currentState()?->connection; + set { + $this->state()->connection = $value; + } + } /** * The paths to all of the migration files. @@ -62,7 +71,14 @@ class Migrator /** * The output interface implementation. */ - protected ?OutputStyle $output = null; + protected ?OutputStyle $output { + get => $this->currentState()?->output; + set { + $this->state()->output = $value; + } + } + + private ?MigratorState $nonCoroutineState = null; /** * The pending migrations to skip. @@ -108,6 +124,18 @@ public function run(array|string $paths = [], array $options = []): array return $migrations; } + /** + * Get the migration files eligible to run, respecting registered exclusions. + * + * @param string[] $files + * @param string[] $ran + * @return string[] + */ + public function getPendingMigrations(array $files, array $ran): array + { + return $this->pendingMigrations($files, $ran); + } + /** * Get the migration files that have not yet run. * @@ -603,6 +631,8 @@ public function getMigrationName(string $path): string /** * Register a custom migration path. + * + * Boot-only. Paths remain on the shared migrator and affect every subsequent run. */ public function path(string $path): void { @@ -755,9 +785,8 @@ public function usingConnection(?string $name, callable $callback): mixed * swapped name is propagated via coroutine Context (so Schema/DB facade * calls during migration resolve correctly), via the repository source * (so the migrations table lands on the same target), and via the stored - * connection (so subsequent resolveConnection() calls use it). Uses - * CoroutineContext rather than $this->resolver->setDefaultConnection() so - * no worker-global state is mutated — concurrent coroutines are unaffected. + * connection (so subsequent resolveConnection() calls use it). The migrator + * and database repository keep this mutable state in the current coroutine. */ public function setConnection(?string $name): void { @@ -875,6 +904,41 @@ public function setOutput(OutputStyle $output): static return $this; } + /** + * Get existing invocation state without allocating it. + */ + private function currentState(): ?MigratorState + { + if (! Coroutine::inCoroutine()) { + return $this->nonCoroutineState; + } + + $state = CoroutineContext::get(self::STATE_CONTEXT_KEY_PREFIX . spl_object_id($this)); + + // A released object's ID may be reused while its context entry survives. + return $state?->owner->get() === $this ? $state : null; + } + + /** + * Get the current invocation's connection and output state. + */ + private function state(): MigratorState + { + if (($state = $this->currentState()) !== null) { + return $state; + } + + $state = new MigratorState(WeakReference::create($this)); + + if (Coroutine::inCoroutine()) { + CoroutineContext::set(self::STATE_CONTEXT_KEY_PREFIX . spl_object_id($this), $state); + } else { + $this->nonCoroutineState = $state; + } + + return $state; + } + /** * Write to the console's output. * diff --git a/src/database/src/Migrations/MigratorState.php b/src/database/src/Migrations/MigratorState.php new file mode 100644 index 0000000000..894e90ccc5 --- /dev/null +++ b/src/database/src/Migrations/MigratorState.php @@ -0,0 +1,33 @@ + $owner + */ + public function __construct(public readonly WeakReference $owner) + { + } + + /** + * Inherit the connection and output without sharing subsequent assignments. + */ + public function replicate(): static + { + return clone $this; + } +} diff --git a/src/docs/configuration.md b/src/docs/configuration.md index f4657e4585..c0fde52527 100644 --- a/src/docs/configuration.md +++ b/src/docs/configuration.md @@ -49,6 +49,13 @@ Or, to explore a specific configuration file's values in detail, you may use the php artisan config:show database ``` +Use the `--json` option to read a configuration section or individual value from a script: + +```shell +php artisan config:show database --json +php artisan config:show app.debug --json +``` + ## Environment Configuration @@ -75,6 +82,8 @@ However, it is possible to encrypt your environment file using Hypervel's built- Before loading your application's environment variables, Hypervel determines if an `APP_ENV` environment variable has been externally provided or if the `--env` CLI argument has been specified. If so, Hypervel will attempt to load an `.env.[APP_ENV]` file if it exists. If it does not exist, the default `.env` file will be loaded. +The `--env` option also applies when starting the application server, for example `php artisan serve --env=staging`. + ### Environment Variable Types @@ -371,6 +380,8 @@ You should typically run the `php artisan config:cache` command as part of your Once the configuration has been cached, your application's `.env` file will not be loaded by the framework during requests or Artisan commands; therefore, the `env` function will only return external, system level environment variables. +If a running server started without cached configuration, reloading its workers applies the new cache, but a full server restart is needed to remove inherited `.env` values from runtime `env` calls. + For this reason, you should ensure you are only calling the `env` function from within your application's configuration (`config`) files. You can see many examples of this by examining Hypervel's default configuration files. Configuration values may be accessed from anywhere in your application using the `config` function [described above](#accessing-configuration-values). The `config:clear` command may be used to purge the cached configuration: diff --git a/src/docs/doctor.md b/src/docs/doctor.md new file mode 100644 index 0000000000..717f5d6993 --- /dev/null +++ b/src/docs/doctor.md @@ -0,0 +1,528 @@ +# Doctor + +- [Introduction](#introduction) +- [Installation](#installation) +- [Running Doctor](#running-doctor) +- [Diagnostic Statuses](#diagnostic-statuses) +- [Selecting Diagnostics](#selecting-diagnostics) +- [Configuring Doctor](#configuring-doctor) +- [Environment Modes](#environment-modes) +- [Default Diagnostics](#default-diagnostics) +- [Creating Diagnostics](#creating-diagnostics) + - [Diagnostic Helpers](#diagnostic-helpers) +- [Registering Diagnostics](#registering-diagnostics) +- [Running Doctor Programmatically](#running-doctor-programmatically) +- [Output Formats](#output-formats) +- [Credits](#credits) + + +## Introduction + +Hypervel Doctor diagnoses common configuration, environment, and infrastructure problems in your application. + +Each diagnostic is a single check. It inspects one thing, such as whether Hypervel can write to its storage directories, and reports one of several statuses. A diagnostic may also offer a fix when the repair is safe and deterministic. Other issues are reported with remediation steps instead. + + +## Installation + +Install Hypervel Doctor using Composer: + +```bash +composer require hypervel/doctor --dev +``` + +If you want to run Doctor in deployments that omit development dependencies, install it without `--dev`. + + +## Running Doctor + +Once installed, the package registers the `doctor` Artisan command: + +```bash +php artisan doctor +``` + +When a failing diagnostic can be fixed, Doctor reports the problem and prompts before applying the fix: + +```text +Storage is writable: The application cannot write to every required storage directory. + + Make the storage directories writable? (yes/no) [yes] +``` + +Some fixes offer a choice of repairs instead of a yes/no confirmation. When the default cache store is unreachable on a developer machine, for example, Doctor checks the configured `file`, `database`, and `redis` alternatives. It offers usable stores and eligible database stores whose default cache tables can be created. Declining the switch leaves the failure in the report with its remediation text. The database creation option also runs all pending migrations, as its label explains. + +To run available fixes without prompting, pass the `--fix` option: + +```bash +php artisan doctor --fix +``` + +First-party fixes cover deterministic local repairs such as creating a missing `.env`, generating `APP_KEY`, disabling production debug mode, adding `.env` to `.gitignore`, creating the public storage link, and repairing writable storage directories. Because `--fix` never prompts, it applies every fix that does not require a choice; fixes that need one are reported as ordinary failures with their remediation text. + +Environment-file checks use the file selected by your application. A missing environment file is skipped in production, where deployment variables may be supplied externally. Doctor does not untrack files from Git or replace conflicting storage links automatically. + +Run repairs as maintenance operations without concurrent application traffic. Repairs may change deployment files, run migrations, or update process-wide configuration. After changing deployment settings, run `php artisan server:reload` or restart the server so running workers use the new settings. + +> [!NOTE] +> Fixes are only available with the CLI and agent [output formats](#output-formats). With agent output, `--fix` applies every available deterministic fix and reports the outcomes in the payload's `fixes` array. Doctor rejects `--fix` with the JSON and GitHub report formats so machine-readable reports never mutate the application. + + +## Diagnostic Statuses + +Every diagnostic returns one of the following statuses. Doctor uses them to render output and to determine the command's exit code: + +| Status | Meaning | Affects exit code | +| -------- | ------------------------------------------------------- | -------------------------- | +| `pass` | The check succeeded and nothing is wrong. | No | +| `notice` | Informational context worth surfacing to the developer. | No | +| `warn` | A potential problem that may not require action. | Only with `--fail-on=warn` | +| `fail` | The check found a problem that should be resolved. | Yes | +| `skip` | The check did not apply to the current environment. | No | +| `error` | The diagnostic threw an exception while running. | Yes | + +By default, the command exits with a failing status when a diagnostic fails or errors. Use `--fail-on=warn` to also fail on warnings, or `--fail-on=never` when Doctor should only report issues. + + +### Bailing on Failure + +Use `--bail` to stop after the first diagnostic that fails or errors. Warnings, notices, passes, and skips do not stop the run: + +```bash +php artisan doctor --bail +``` + +The flag works with every output format and may be combined with diagnostic selectors. Programmatic runs may enable the same behavior with `Doctor::bail()->run()`. + + +## Selecting Diagnostics + +Diagnostics may be selected or excluded by class name, group, package, or package wildcard. Multiple values may be passed either by repeating the option or by separating values with commas. + +```bash +php artisan doctor --only=storage + +php artisan doctor --only=StorageIsWritable + +php artisan doctor --only=vendor/package + +php artisan doctor --except=hypervel/* + +php artisan doctor --except=hypervel/doctor + +php artisan doctor --except=storage +``` + + +## Configuring Doctor + +To configure persistent diagnostic selection and environment modes, publish Doctor's configuration file: + +```bash +php artisan vendor:publish --tag=doctor-config +``` + +The `only` and `except` options accept the same selectors as the command line: diagnostic class names, groups, packages, or package wildcards. + +```php +'only' => [ + // 'hypervel/doctor', + // 'vendor/*', + // 'security', +], + +'except' => [ + // \Hypervel\Doctor\Diagnostics\EnvironmentFileIsGitIgnored::class, +], +``` + +Configured `only` selectors act as a persistent allowlist. Passing `--only` at runtime narrows that allowlist further, while configured `except` selectors and `--except` are combined. + + +## Environment Modes + +Some facts about an application depend on its environment. A `sync` queue connection is a sensible default on a developer's machine, but in production it means queued jobs run inside web requests. Uncached bootstrap files are normal while code is changing; in production, caching them can improve startup. Debug mode is useful locally and a security problem on a public server. + +To judge these diagnostics, Doctor resolves the application to one of two modes: + +| Mode | Expectations | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `local` | The application is under active development. Debug mode, `sync` queues, and uncached bootstrap files are normal. | +| `production` | The application serves real traffic. Debug mode is a security risk, queues should run asynchronously, and bootstrap files should be cached. | + +Missing bootstrap caches warn in production but pass locally. A partial production cache produces a notice listing the cached and uncached types. Complete caches pass in production but produce a notice locally, since a stale cache is a common reason recent changes do not appear during development. These checks cover configuration, routes, and events; compiled views are created on demand and do not indicate whether the deployment is optimized. + +Doctor recognizes Hypervel's conventional `local`, `production`, and `staging` environment names out of the box. If your application uses other names, group each one under the appropriate Doctor mode in the configuration file: + +```php +'environments' => [ + 'local' => ['local', 'dev'], + 'production' => ['production', 'staging', 'qa'], +], +``` + +Any environment not listed is treated as `production`, so unfamiliar environments are held to the strictest expectations rather than quietly excused. An unsupported mode name, an environment list that is not an array, or an environment assigned to both modes throws an exception. + +Custom diagnostics may branch on the current mode. Here, a `sync` queue connection passes on a developer's machine but warns in production: + +```php +use Hypervel\Doctor\EnvironmentMode; +use Hypervel\Doctor\Results\DiagnosticResult; + +public function check(): DiagnosticResult +{ + $connection = config()->string('queue.default'); + + if (config("queue.connections.{$connection}.driver") !== 'sync') { + return $this->skip('not-sync'); + } + + if (EnvironmentMode::current()->isProduction()) { + return $this->warn('sync-in-production'); + } + + return $this->pass('sync-local'); +} +``` + +The `pass` and `warn` methods build results from the diagnostic's named messages, which are covered in [Creating Diagnostics](#creating-diagnostics). + + +## Default Diagnostics + +Doctor ships with a focused suite of diagnostics that cover common configuration, environment, dependency, database, queue, and storage problems. The default suite includes: + +- **Environment** — the selected environment file, `APP_KEY`, PHP version constraints, and required and recommended PHP extensions. +- **Composer** — installed dependencies, optimized autoload validation, lock-file consistency, and local repairs for eligible dependency problems. +- **Configuration** — configuration files can be loaded and cached, configuration values required by the active drivers are set, and bootstrap cache files are reported when their presence does not match the current environment. +- **Database** — the default connection is reachable, including its selectable read and write records. Explicit `::read` or `::write` defaults check that role. Doctor also checks the selected SQLite database file and can apply pending migrations automatically in local environments. +- **Cache, queue, scheduler, and session** — selected drivers and active Redis data and lock connections are reachable, degraded failover capacity is reported, `sync` queues are flagged outside local environments, and scheduled tasks prompt advice to keep `schedule:run` running. Background and deferred queues run in coroutines and need no external worker; null queues intentionally discard jobs. +- **Storage** — the default filesystem disk is reachable, required directories are writable, and the `storage:link` symlink exists when expected. +- **Security** — debug mode matches the environment, `.env` is ignored, and Composer dependencies pass the configured audit policy, including vulnerability, abandoned-package, and filter-list checks. + +Platform checks read the root Composer manifest and installed package metadata. This includes dependency extension constraints, such as the framework's Swoole requirement, without keeping a second version list. Compatible Composer providers and replacements can satisfy extension requirements. Root development requirements apply only when the installed metadata records a development installation. Checks inspect the PHP process running Doctor, not Composer's emulated platform. + +Redis feature checks use the framework's shared Redis requirements. Higher client and server requirements apply only when active cache stores use `any` tag mode or Redis sessions enable user session tracking. Ordinary Redis use requires only the base client version; separate lock connections do not inherit hash-field requirements. Server version inspection on a cluster checks its default node. + +SQS and Beanstalkd reachability checks read queue sizes without publishing jobs. The SQS check requires the `sqs:GetQueueAttributes` permission. + +Doctor runs after application startup. Startup guards still reject unsupported runtime settings and invalid application timezones before the command can run. Doctor does not bypass those guards. + + +## Creating Diagnostics + +A diagnostic is a single class that, like an Artisan command, can declare its definition as properties. Extend `Hypervel\Doctor\Diagnostic` and implement a `check` method that returns a `DiagnosticResult`. When `$name` or `$group` are not set, Doctor derives defaults from the class name. + +Bundled diagnostics assume the standard behavior of built-in driver names. If your application replaces that behavior, select custom diagnostics for those services. + +The `make:diagnostic` Artisan command scaffolds a diagnostic into `app/Doctor/Diagnostics`. The command asks whether the diagnostic should offer a fix, or accepts the `--fixable` option directly: + +```bash +php artisan make:diagnostic HorizonIsRunning --fixable +``` + +To offer a fix, implement the `Hypervel\Doctor\Contracts\Fixable` contract and mark each repairable failure with `->fixable()`. Doctor only attempts fixes for explicitly fixable `fail` results from diagnostics that implement the contract. Other failures and unexpected `error` results are never fixed automatically. Pass one or more `EnvironmentMode` values to limit where an automatic fix may run, such as `->fixable(EnvironmentMode::Local)` for a developer-machine-only repair. + +The following diagnostic checks whether the application's report directory exists and offers to create it in local environments: + +```php + 'The report directory exists.', + + 'missing' => Message::make( + summary: 'The report directory is missing.', + remediation: 'Create the app/reports directory within application storage.', + confirmation: 'Create the report directory?', + ), + + 'created' => 'The report directory was created.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (File::isDirectory(storage_path('app/reports'))) { + return $this->pass('exists'); + } + + return $this->fail('missing')->fixable(EnvironmentMode::Local); + } + + /** + * Create the report directory. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + File::ensureDirectoryExists(storage_path('app/reports')); + + return $this->fixed('created'); + } +} +``` + +`ensureDirectoryExists` throws if the directory cannot be created. Doctor reports unexpected exceptions from a repair as an error result. + +Diagnostics should explain what failed and how to recover. Copy lives in the `messages()` registry: a plain string is used as the result's summary, while `Message::make()` may also provide remediation text, documentation links, or a confirmation prompt for fixes. + +Use `Link::docs()` for Hypervel documentation and `Link::to()` for other destinations. Documentation links use the same versionless page and section URLs in every output format: + +```php +Message::make( + summary: 'Queued jobs run synchronously in production.', + remediation: 'Configure a background queue driver.', +)->link(Link::docs('queues', 'connections-vs-queues')); +``` + +Statuses are declared where the decision is made. Return `$this->pass()`, `$this->fail()`, `$this->warn()`, `$this->notice()`, `$this->skip()`, or `$this->error()` from `check()`, and `$this->fixed()` or `$this->fixFailed()` from `fix()`. Each result also receives a stable machine-readable code derived from the class and message names, such as `report-directory-exists.missing`. + +Summaries and remediation text may interpolate values using `{placeholder}` tokens supplied at the return site: + +```php +'unsatisfied' => Message::make( + summary: 'PHP {version} does not satisfy [{constraint}].', + remediation: 'Use a PHP binary that satisfies the composer.json PHP constraint.', +), +``` + +```php +return $this->fail('unsatisfied', [ + 'version' => PHP_VERSION, + 'constraint' => $constraint, +]); +``` + +Reserve tokens for short identifying values such as versions, paths, and counts. Attach unbounded evidence such as exception messages, process output, or lists of failures with `withDetails()` instead. + +If the check gathers state the fix will need, store it with `withContext()` on the result. Mark only outcomes the fix can handle with `fixable()`, and only implement `Fixable` when the repair is predictable and safe. Scope a fix to `EnvironmentMode::Local` to limit fixes to a development environment. + +When a failure has several valid repairs and the right one is a human decision, declare the choices with `fixOptions()` after `fixable()`. The interactive CLI renders them as a select list using the result's confirmation text as the prompt label, and the chosen option value is passed to `fix()`: + +```php +return $this->fail('unreachable') + ->fixable(EnvironmentMode::Local) + ->fixOptions(['file' => 'File', 'redis' => 'Redis']); +``` + +```php +public function fix(DiagnosticResult $result, ?string $option = null): FixResult +{ + // $option is one of the declared option values... +} +``` + +Compute the options in `check()` and filter them to choices that will actually succeed — probe candidate services, verify client packages, and drop anything that would fail — so the select list only offers working repairs. A result with no viable options should stay non-fixable and rely on its remediation text. + +Every select list ends with an entry that declines the fix, `Skip — leave unfixed` by default. Pass a `decline` label when naming the current selection reads better, such as `Keep Redis (repair it manually)` — choosing it applies nothing, so the failure renders with its remediation: + +```php +->fixOptions(['file' => 'File'], decline: 'Keep Redis (repair it manually)'); +``` + + +### Diagnostic Helpers + +Many applications and packages end up writing the same kinds of checks, so Doctor provides helpers in the `Hypervel\Doctor\Support` namespace for the recurring mechanics. Reach for these before re-implementing the logic inside a diagnostic. + +#### Configuration Values + +The `Configured` helper reads configuration defensively. Diagnostics must be able to inspect misconfigured applications without failing before they can report, so these methods never throw on unexpected types the way the config repository's typed accessors can. + +`Configured::string()` returns a configured string, treating empty or non-string values as missing: + +```php +use Hypervel\Doctor\Support\Configured; + +$connection = Configured::string('queue.default', 'database'); +``` + +`Configured::missing()` returns the given keys that do not hold a usable value. Null values, blank strings, and empty arrays are treated as missing, so a "package requires these configuration values" diagnostic reduces to: + +```php +public function check(): DiagnosticResult +{ + $missing = Configured::missing([ + 'services.stripe.key', + 'services.stripe.secret', + ]); + + if ($missing === []) { + return $this->pass('set'); + } + + return $this->fail('missing')->withDetails(Details::bullets($missing)); +} +``` + +#### Active Drivers + +Default configuration values often point at wrapper drivers: the default log channel may be a `stack`, and a default mailer may be a `failover` or `roundrobin` transport. The `ActiveDrivers` helper finds the concrete services in use: + +```php +use Hypervel\Doctor\Support\ActiveDrivers; + +$channels = ActiveDrivers::logChannels(Configured::string('logging.default', 'stack')); + +$mailers = ActiveDrivers::mailers(Configured::string('mail.default', 'log')); + +$stores = ActiveDrivers::cacheStores(config()->string('cache.default')); + +$connections = ActiveDrivers::queueConnections(config()->string('queue.default')); + +$redis = ActiveDrivers::redisConnections(); +``` + +The methods follow nested wrappers and return unique names in configuration order. Cache and queue discovery reports circular definitions as errors. Logging and mail discovery stops at repeated names. + +#### Result Details + +The `Details` helper formats the evidence attached to results with `withDetails()`. `Details::bullets()` renders a list of strings as bullets, `Details::failures()` renders keyed failure messages, and `Details::processOutput()` selects the most useful stream from a finished process, preferring error output and falling back when both streams are empty: + +```php +use Hypervel\Doctor\Support\Details; + +Details::bullets(['services.stripe.key', 'services.stripe.secret']); + +Details::failures(['media' => 'The disk root is not writable.']); + +Details::processOutput($process->output(), $process->errorOutput(), 'The command produced no output.'); +``` + + +## Registering Diagnostics + +Applications may register diagnostics through the Doctor facade, typically from a service provider: + +```php +use App\Doctor\Diagnostics\ReportDirectoryExists; +use Hypervel\Doctor\Facades\Doctor; + +public function boot(): void +{ + Doctor::diagnostic(ReportDirectoryExists::class); +} +``` + +Packages use the same registration API from their service providers: + +```php +use Hypervel\Doctor\Facades\Doctor; +use Vendor\Package\Diagnostics\HorizonIsRunning; + +public function boot(): void +{ + Doctor::diagnostic(HorizonIsRunning::class); +} +``` + +Reports show each diagnostic's source next to its name. The source is the Composer package that provides the diagnostic: + +```text +[fail] Storage is writable (hypervel/doctor): The application cannot write to every required storage directory. +[pass] SQLite database exists (acme/application): The SQLite database file exists. +[warn] Horizon is running (hypervel/horizon): Horizon is not currently running. +``` + +Register diagnostics during provider boot. The registry is shared for the worker lifetime. Doctor resolves a fresh instance for each diagnostic invocation unless the application explicitly binds that diagnostic with another lifetime. Keep health results local to the invocation instead of storing them in static properties. + + +## Running Doctor Programmatically + +Doctor may also be run without the Artisan command. The `run` method runs the registered diagnostics and returns a `DiagnosticReport`: + +```php +use Hypervel\Doctor\Facades\Doctor; + +$report = Doctor::run(); + +if ($report->hasFailures()) { + // ... +} +``` + +Programmatic runs honor the `only` and `except` selectors from Doctor's configuration file. To narrow a run further, constrain it with `only` and `except` before calling `run`. Selectors may reference a diagnostic class, class basename, group, or package name — the same values accepted by the command's `--only` and `--except` options: + +```php +$report = Doctor::only('security') + ->except(SomeDiagnostic::class) + ->run(); +``` + +Repeated `only` calls intersect, so each call narrows the run within the previous constraints. + +To apply fixes as part of a run, configure the run with `fixUsing`. The callback receives each failing diagnostic that offers a fix and decides what to do: return `false` to skip the fix, `true` to apply a plain fix, or one of the result's fix option values to apply the fix with that choice. When any fixes are applied, Doctor re-runs the diagnostics so the report reflects the repaired application: + +```php +$report = Doctor::fixUsing( + fn ($outcome) => $outcome->fixRequiresOption() ? false : true, +)->run(); + +$report->fixes(); +``` + +Returning `true` for an outcome whose result declares fix options throws a `LogicException` — a choice-based fix never runs without a choice. + +Use programmatic repairs only during maintenance without concurrent traffic, just as with `doctor --fix`. Coroutine cancellation propagates through checks and repairs instead of becoming a diagnostic failure. + + +## Output Formats + +Doctor renders readable CLI output by default. JSON and GitHub Actions annotations are also available: + +```bash +php artisan doctor --format=json + +php artisan doctor --format=github +``` + +### Agent-Optimized Output + +When Doctor detects that it is running inside an AI coding agent such as Claude Code or Cursor — using [Laravel Agent Detector](https://github.com/laravel/agent-detector) — it defaults to an agent-optimized format instead of CLI output. The format follows the [Laravel PAO](https://github.com/laravel/pao) convention: a single line of JSON with aggregate counts up front and only actionable outcomes itemized, letting agents use Doctor as a minimal, parseable completion gate: + +```json +{"tool":"doctor","result":"failed","diagnostics":27,"failed":1,"warnings":0,"notices":0,"passed":20,"skipped":6,"issues":[{"name":".env file exists","status":"fail","summary":"The selected environment file [/var/www/app/.env] does not exist.","fix":"Copy [/var/www/app/.env.example] to [/var/www/app/.env], then review the copied values.","fixable":true,"links":{"Configuration documentation":"https://hypervel.org/docs/configuration#environment-configuration"}}]} +``` + +Issues marked `fixable` may be fixed by re-running Doctor with `--fix`, which applies every available deterministic fix, re-runs the diagnostics, and appends the fix outcomes to the payload. Issues that carry an `options` map instead of the `fixable` flag require a choice `--fix` will not make: the agent should apply the change itself following the issue's `fix` remediation, or escalate the shortlist of options to a human. + +An explicit `--format` option always overrides detection, and the agent format may also be requested directly: + +```bash +php artisan doctor --format=cli + +php artisan doctor --format=agent +``` + +To preview agent output outside an agent, set the `AI_AGENT` environment variable: `AI_AGENT=test php artisan doctor`. + + +## Credits + +Hypervel Doctor is ported from [Laravel Doctor](https://github.com/laravel/doctor). diff --git a/src/docs/documentation.md b/src/docs/documentation.md index d07390cf27..0f157e39d5 100644 --- a/src/docs/documentation.md +++ b/src/docs/documentation.md @@ -100,6 +100,7 @@ - [Mocking](/docs/{{version}}/mocking) - ## Packages - [API Client](/docs/{{version}}/api-client) + - [Doctor](/docs/{{version}}/doctor) - [Fortify](/docs/{{version}}/fortify) - [gRPC](/docs/{{version}}/grpc) - [Head](/docs/{{version}}/head) diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 1fca13616d..d4b30c7bd7 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -23,6 +23,7 @@ - [Configuration](#configuration) - [Other API Differences](#other-api-differences) - [Development Processes](#development-processes) + - [Doctor](#doctor) - [Scheduling](#scheduling) - [Maintenance Mode](#maintenance-mode) - [HTTP Client and Concurrency](#http-client-and-concurrency) @@ -501,6 +502,11 @@ Many Laravel APIs have direct Hypervel equivalents under the `Hypervel` namespac For `artisan dev`, install `@laravel/multiplex` locally when using pnpm or Yarn. The default server process uses `hypervel/watcher`; Pail is not included. See [The Dev Command](/docs/{{version}}/artisan#the-dev-command). + +### Doctor + +Replace `laravel/doctor` with `hypervel/doctor` and the `Laravel\Doctor` namespace with `Hypervel\Doctor`. Rename calls to `EnvironmentMode::fromLaravelEnvironment()` to `fromHypervelEnvironment()`. Remove explicit registrations of `ApplicationTimezoneIsValid`: Hypervel rejects invalid timezones during startup, before Doctor can run. Register custom diagnostics during provider boot, and run programmatic repairs without concurrent application traffic. See [Doctor](/docs/{{version}}/doctor). + ### Scheduling diff --git a/src/doctor/LICENSE.md b/src/doctor/LICENSE.md new file mode 100644 index 0000000000..670aace448 --- /dev/null +++ b/src/doctor/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/doctor/README.md b/src/doctor/README.md new file mode 100644 index 0000000000..50f3147aa3 --- /dev/null +++ b/src/doctor/README.md @@ -0,0 +1,12 @@ +Doctor for Hypervel +=== + +Documentation: https://hypervel.org/docs/doctor + +## Differences From Laravel + +`ApplicationTimezoneIsValid` is not provided. Hypervel validates the application timezone during startup and stops before commands run when it is invalid. + +Use `EnvironmentMode::fromHypervelEnvironment()` in place of `fromLaravelEnvironment()`. + +Ported from: https://github.com/laravel/doctor diff --git a/src/doctor/composer.json b/src/doctor/composer.json new file mode 100644 index 0000000000..3d7b3b634b --- /dev/null +++ b/src/doctor/composer.json @@ -0,0 +1,68 @@ +{ + "name": "hypervel/doctor", + "description": "First-party diagnostics for Hypervel applications.", + "license": "MIT", + "type": "library", + "keywords": [ + "php", + "doctor", + "diagnostics", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "require": { + "php": "^8.4", + "composer-runtime-api": "^2.2", + "composer/semver": "^3.5.1", + "hypervel/cache": "^0.4", + "hypervel/collections": "^0.4", + "hypervel/conditionable": "^0.4", + "hypervel/console": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/database": "^0.4", + "hypervel/filesystem": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/process": "^0.4", + "hypervel/prompts": "^0.4", + "hypervel/queue": "^0.4", + "hypervel/redis": "^0.4", + "hypervel/session": "^0.4", + "hypervel/support": "^0.4", + "laravel/agent-detector": "^2.0.5", + "monolog/monolog": "^3.10", + "symfony/console": "^8.1.2", + "symfony/finder": "^8.1" + }, + "autoload": { + "psr-4": { + "Hypervel\\Doctor\\": "src/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "hypervel": { + "providers": [ + "Hypervel\\Doctor\\DoctorServiceProvider" + ] + } + } +} diff --git a/src/doctor/config/doctor.php b/src/doctor/config/doctor.php new file mode 100644 index 0000000000..288a94b5f2 --- /dev/null +++ b/src/doctor/config/doctor.php @@ -0,0 +1,42 @@ + [ + // 'hypervel/doctor', + // 'vendor/*', + // 'security', + ], + + 'except' => [ + // \Hypervel\Doctor\Diagnostics\EnvironmentFileIsGitIgnored::class, + ], + + /* + |-------------------------------------------------------------------------- + | Environment Modes + |-------------------------------------------------------------------------- + | + | Group Hypervel environment names by the Doctor mode whose expectations + | should apply. Any environment not listed here is treated as + | production, the strictest mode. + | + */ + + 'environments' => [ + 'local' => ['local'], + 'production' => ['production', 'staging'], + ], +]; diff --git a/src/doctor/src/Console/DiagnosticMakeCommand.php b/src/doctor/src/Console/DiagnosticMakeCommand.php new file mode 100644 index 0000000000..fbc5c307c5 --- /dev/null +++ b/src/doctor/src/Console/DiagnosticMakeCommand.php @@ -0,0 +1,85 @@ +input()->boolean('fixable') + ? $this->resolveStubPath('/stubs/diagnostic.fixable.stub') + : $this->resolveStubPath('/stubs/diagnostic.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + */ + protected function resolveStubPath(string $stub): string + { + $customPath = $this->hypervel->basePath(trim($stub, '/')); + + return file_exists($customPath) ? $customPath : __DIR__ . $stub; + } + + /** + * Get the default namespace for the class. + */ + protected function getDefaultNamespace(string $rootNamespace): string + { + return $rootNamespace . '\Doctor\Diagnostics'; + } + + /** + * Interact with the user, asking whether the diagnostic should offer a fix. + */ + protected function interact(InputInterface $input, OutputInterface $output): void + { + parent::interact($input, $output); + + if (! $input->getOption('fixable')) { + $input->setOption('fixable', confirm('Should the diagnostic offer a fix?', default: false)); + } + } + + /** + * Get the console command options. + * + * @return array> + */ + protected function getOptions(): array + { + return [ + ['fixable', null, InputOption::VALUE_NONE, 'Generate a diagnostic that can fix the failure it detects'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the diagnostic already exists'], + ]; + } +} diff --git a/src/doctor/src/Console/DoctorCommand.php b/src/doctor/src/Console/DoctorCommand.php new file mode 100644 index 0000000000..73ddf18bab --- /dev/null +++ b/src/doctor/src/Console/DoctorCommand.php @@ -0,0 +1,393 @@ +format(); + $failOn = $this->failOn(); + + if ($format === null || $failOn === null || ! $this->fixOptionIsValidFor($format)) { + return self::FAILURE; + } + + return match ($format) { + 'cli' => $this->runCli($doctor, $failOn), + 'json' => $this->runJson($doctor, $failOn), + 'github' => $this->runGithub($doctor, $failOn), + 'agent' => $this->runAgent($doctor, $failOn), + }; + } + + /** + * Get the requested output format. + * + * @return null|'agent'|'cli'|'github'|'json' + */ + protected function format(): ?string + { + $format = $this->input()->string('format')->toString(); + + if (! in_array($format, ['cli', 'json', 'github', 'agent'], true)) { + $this->error('The --format option must be one of: cli, json, github, agent.'); + + return null; + } + + if (! $this->input->hasParameterOption('--format') && AgentDetector::detect()->isAgent) { + return 'agent'; + } + + return $format; + } + + /** + * Get the configured exit-code threshold. + * + * @return null|'fail'|'never'|'warn' + */ + protected function failOn(): ?string + { + $failOn = $this->input()->string('fail-on')->toString(); + + if (! in_array($failOn, [Status::Fail->value, Status::Warn->value, 'never'], true)) { + $this->error('The --fail-on option must be one of: fail, warn, never.'); + + return null; + } + + return $failOn; + } + + /** + * Determine whether the fix option is valid for the format. + * + * @param 'agent'|'cli'|'github'|'json' $format + */ + protected function fixOptionIsValidFor(string $format): bool + { + if ($format === 'cli' || $format === 'agent' || ! $this->input()->boolean('fix')) { + return true; + } + + $this->error('The --fix option may only be used with the cli or agent output formats.'); + + return false; + } + + /** + * Run Doctor for CLI output. + * + * @param 'fail'|'never'|'warn' $failOn + */ + protected function runCli(Doctor $doctor, string $failOn): int + { + $report = $this->runner($doctor) + ->when($this->shouldUseTasks(), fn (PendingRun $runner) => $runner->observeUsing($this->taskObserver())) + ->fixUsing(fn (DiagnosticOutcome $outcome): bool|string => $this->fixDecision($outcome)) + ->beforeRerun(function (): void { + $this->restorePrompts(); + + info('Re-running diagnostics after applying fixes...'); + }) + ->run(); + + if ($report->diagnostics() === []) { + if (! $doctor->hasDiagnostics()) { + info('No diagnostics registered.'); + } else { + warning('No diagnostics matched the selected filters.'); + } + + return self::SUCCESS; + } + + $this->renderer()->render($report); + + return $this->exitCode($report, $failOn); + } + + /** + * Run Doctor for JSON output. + * + * @param 'fail'|'never'|'warn' $failOn + */ + protected function runJson(Doctor $doctor, string $failOn): int + { + $report = $this->runner($doctor)->run(); + + $this->output->writeln(json_encode($report, JSON_THROW_ON_ERROR), OutputInterface::OUTPUT_RAW); + + return $this->exitCode($report, $failOn); + } + + /** + * Run Doctor for GitHub Actions output. + * + * @param 'fail'|'never'|'warn' $failOn + */ + protected function runGithub(Doctor $doctor, string $failOn): int + { + $report = $this->runner($doctor)->run(); + + foreach ((new GithubRenderer)->annotations($report) as $annotation) { + $this->output->writeln($annotation, OutputInterface::OUTPUT_RAW); + } + + return $this->exitCode($report, $failOn); + } + + /** + * Run Doctor for agent-optimized output. + * + * @param 'fail'|'never'|'warn' $failOn + */ + protected function runAgent(Doctor $doctor, string $failOn): int + { + $report = $this->runner($doctor) + ->when($this->input()->boolean('fix'), fn (PendingRun $runner) => $runner->fixUsing( + static fn (DiagnosticOutcome $outcome): bool => ! $outcome->fixRequiresOption(), + )) + ->run(); + + $this->output->writeln((new AgentRenderer)->render($report), OutputInterface::OUTPUT_RAW); + + return $this->exitCode($report, $failOn); + } + + /** + * Begin a run configured from the command options. + */ + protected function runner(Doctor $doctor): PendingRun + { + return $doctor->only(...$this->optionList('only')) + ->except(...$this->optionList('except')) + ->bail($this->input()->boolean('bail')); + } + + /** + * Get a normalized list option. + * + * @return list + */ + protected function optionList(string $name): array + { + return array_values(array_filter($this->input()->array($name), is_string(...))); + } + + /** + * Determine whether Hypervel Prompts tasks should be used. + */ + protected function shouldUseTasks(): bool + { + return $this->output->isDecorated() + && $this->input->isInteractive(); + } + + /** + * Get the callback that observes each diagnostic group as a console task. + * + * @return Closure(string, GroupContinuation): void + */ + protected function taskObserver(): Closure + { + return function (string $group, Closure $next): void { + task( + label: sprintf('%s diagnostics', ucfirst($group)), + limit: 0, + keepSummary: true, + callback: fn (Logger $logger) => $this->observeGroupTask($next, $logger), + ); + }; + } + + /** + * Observe a diagnostic group within a console task. + * + * @param GroupContinuation $next + */ + protected function observeGroupTask(Closure $next, Logger $logger): void + { + $next( + before: function (Diagnostic $diagnostic) use ($logger): void { + $logger->subLabel($diagnostic->name); + }, + after: function (DiagnosticOutcome $outcome) use ($logger): void { + $this->logOutcome($logger, $outcome); + }, + ); + + $logger->subLabel(''); + } + + /** + * Write a diagnostic outcome to the task logger. + */ + protected function logOutcome(Logger $logger, DiagnosticOutcome $outcome): void + { + $name = $outcome->diagnostic->name; + + match ($outcome->result->status) { + Status::Pass => $logger->success($name), + Status::Notice, Status::Skip => $logger->line($name . ': ' . $outcome->result->summary), + Status::Warn => $logger->warning($name . ': ' . $outcome->result->summary), + Status::Fail, Status::Error => $logger->error($name . ': ' . $outcome->result->summary), + }; + } + + /** + * Decide whether a fix should be applied and with which option. + */ + protected function fixDecision(DiagnosticOutcome $outcome): bool|string + { + if ($this->input()->boolean('fix')) { + return ! $outcome->fixRequiresOption(); + } + + if ($outcome->fixRequiresOption()) { + return $this->chooseFix($outcome); + } + + $this->restorePrompts(); + + if (! $this->input->isInteractive() || ! Prompt::isInteractive()) { + return false; + } + + $this->renderer()->renderFixConfirmation($outcome); + + return confirm($this->confirmationPrompt($outcome), default: true); + } + + /** + * Let the user choose between the fix options or decline the fix. + * + * Fixes that require a choice are never applied under --fix or without + * an interactive terminal; the failure renders with its remediation. + */ + protected function chooseFix(DiagnosticOutcome $outcome): bool|string + { + $this->restorePrompts(); + + if (! $this->input->isInteractive() || ! Prompt::isInteractive()) { + return false; + } + + $this->renderer()->renderFixConfirmation($outcome); + + $choice = select( + label: $this->confirmationPrompt($outcome), + options: $outcome->result->fixOptions + [ + self::SKIP_FIX => $outcome->result->fixDeclineLabel ?? 'Skip — leave unfixed', + ], + ); + + // PHP coerces numeric-string array keys to integers, so restore the + // declared string option value before it flows back to the runner. + return $choice === self::SKIP_FIX ? false : (string) $choice; + } + + /** + * Restore the prompts configuration. + * + * Fixes may run nested Artisan commands, which re-bind the prompts + * output and fallbacks to the nested command. Without a restore, Doctor's + * remaining output is swallowed and pending confirmations appear to hang. + */ + protected function restorePrompts(): void + { + $this->configurePrompts($this->input); + } + + /** + * Get the confirmation prompt for a diagnostic fix. + */ + protected function confirmationPrompt(DiagnosticOutcome $outcome): string + { + return $outcome->result->confirmation + ?? ($outcome->fixRequiresOption() + ? sprintf('How should "%s" be fixed?', $outcome->diagnostic->name) + : sprintf('Fix "%s"?', $outcome->diagnostic->name)); + } + + /** + * Get the interactive CLI renderer. + */ + protected function renderer(): CliRenderer + { + return $this->renderer ??= new CliRenderer($this->output); + } + + /** + * Determine the command exit code for the report. + * + * @param 'fail'|'never'|'warn' $failOn + */ + protected function exitCode(DiagnosticReport $report, string $failOn): int + { + if ($failOn === 'never') { + return self::SUCCESS; + } + + if ($failOn === Status::Warn->value) { + return $report->hasFailures() || $report->hasWarnings() ? self::FAILURE : self::SUCCESS; + } + + return $report->hasFailures() ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/doctor/src/Console/stubs/diagnostic.fixable.stub b/src/doctor/src/Console/stubs/diagnostic.fixable.stub new file mode 100644 index 0000000000..9e1744e9a7 --- /dev/null +++ b/src/doctor/src/Console/stubs/diagnostic.fixable.stub @@ -0,0 +1,49 @@ + + */ + protected function messages(): array + { + return [ + 'passed' => 'The check passed.', + 'failed' => Message::make( + summary: 'The check failed.', + remediation: 'Describe how to resolve the failure.', + confirmation: 'Apply the fix?', + ), + 'fixed' => 'The failure was fixed.', + 'fix-failed' => 'The failure could not be fixed.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + return $this->fail('failed')->fixable(); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + return $this->fixed('fixed'); + } +} diff --git a/src/doctor/src/Console/stubs/diagnostic.stub b/src/doctor/src/Console/stubs/diagnostic.stub new file mode 100644 index 0000000000..9243cf7c08 --- /dev/null +++ b/src/doctor/src/Console/stubs/diagnostic.stub @@ -0,0 +1,36 @@ + + */ + protected function messages(): array + { + return [ + 'passed' => 'The check passed.', + 'failed' => Message::make( + summary: 'The check failed.', + remediation: 'Describe how to resolve the failure.', + ), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + return $this->pass('passed'); + } +} diff --git a/src/doctor/src/Contracts/Fixable.php b/src/doctor/src/Contracts/Fixable.php new file mode 100644 index 0000000000..6e03a7c1bf --- /dev/null +++ b/src/doctor/src/Contracts/Fixable.php @@ -0,0 +1,19 @@ +name ??= Str::of(class_basename(static::class))->snake(' ')->ucfirst()->toString(); + $this->group ??= Str::kebab(class_basename(static::class)); + } + + /** + * Run the diagnostic. + */ + abstract public function check(): DiagnosticResult; + + /** + * Get the diagnostic's named message definitions. + * + * @return array + */ + protected function messages(): array + { + return []; + } + + /** + * Create a passing diagnostic result from a named message. + * + * @param array $replace + */ + protected function pass(string $message, array $replace = []): DiagnosticResult + { + return $this->diagnosticResult(Status::Pass, $message, $replace); + } + + /** + * Create a failing diagnostic result from a named message. + * + * @param array $replace + */ + protected function fail(string $message, array $replace = []): DiagnosticResult + { + return $this->diagnosticResult(Status::Fail, $message, $replace); + } + + /** + * Create a warning diagnostic result from a named message. + * + * @param array $replace + */ + protected function warn(string $message, array $replace = []): DiagnosticResult + { + return $this->diagnosticResult(Status::Warn, $message, $replace); + } + + /** + * Create a notice diagnostic result from a named message. + * + * @param array $replace + */ + protected function notice(string $message, array $replace = []): DiagnosticResult + { + return $this->diagnosticResult(Status::Notice, $message, $replace); + } + + /** + * Create a skipped diagnostic result from a named message. + * + * @param array $replace + */ + protected function skip(string $message, array $replace = []): DiagnosticResult + { + return $this->diagnosticResult(Status::Skip, $message, $replace); + } + + /** + * Create an error diagnostic result from a named message. + * + * @param array $replace + */ + protected function error(string $message, array $replace = []): DiagnosticResult + { + return $this->diagnosticResult(Status::Error, $message, $replace); + } + + /** + * Create a passing fix result from a named message. + * + * @param array $replace + */ + protected function fixed(string $message, array $replace = []): FixResult + { + return $this->diagnosticFixResult(Status::Pass, $message, $replace); + } + + /** + * Create a failing fix result from a named message. + * + * @param array $replace + */ + protected function fixFailed(string $message, array $replace = []): FixResult + { + return $this->diagnosticFixResult(Status::Fail, $message, $replace); + } + + /** + * Create a diagnostic result from a named message. + * + * @param array $replace + */ + private function diagnosticResult(Status $status, string $message, array $replace = []): DiagnosticResult + { + $definition = $this->message($message); + $tokens = $this->tokens($replace); + + $result = new DiagnosticResult( + $status, + Str::swap($tokens, $definition->summary), + $this->code($message), + ); + + if ($definition->remediation !== null) { + $result->suggest(Str::swap($tokens, $definition->remediation)); + } + + if ($definition->confirmation !== null) { + $result->confirmUsing(Str::swap($tokens, $definition->confirmation)); + } + + foreach ($definition->links as $link) { + $result->link($link->replace($tokens)); + } + + return $result; + } + + /** + * Create a fix result from a named message. + * + * @param array $replace + */ + private function diagnosticFixResult(Status $status, string $message, array $replace = []): FixResult + { + $definition = $this->message($message); + + return new FixResult( + $status, + Str::swap($this->tokens($replace), $definition->summary), + $this->fixCode($message), + ); + } + + /** + * Get a named message definition. + */ + protected function message(string $message): Message + { + $definition = $this->messages()[$message] ?? null; + + if (is_string($definition)) { + return Message::make($definition); + } + + if (! $definition instanceof Message) { + throw new InvalidArgumentException(sprintf( + 'Message [%s] is not defined for diagnostic [%s].', + $message, + static::class, + )); + } + + return $definition; + } + + /** + * Get a machine-readable result code for the message. + */ + protected function code(string $message): string + { + return Str::of(class_basename(static::class)) + ->kebab() + ->append('.' . $message) + ->toString(); + } + + /** + * Get a machine-readable fix result code for the message. + */ + protected function fixCode(string $message): string + { + return Str::of(class_basename(static::class)) + ->kebab() + ->append('.fix.' . $message) + ->toString(); + } + + /** + * Normalize replacement values into Str::swap tokens. + * + * @param array $replace + * @return array + */ + protected function tokens(array $replace): array + { + $tokens = []; + + foreach ($replace as $key => $value) { + $tokens['{' . $key . '}'] = match (true) { + $value === null => '', + is_bool($value) => $value ? 'true' : 'false', + default => (string) $value, + }; + } + + return $tokens; + } + + /** + * Get the Composer package that provides the diagnostic. + */ + public function package(): ?string + { + return DiagnosticSource::resolve(static::class)->package; + } +} diff --git a/src/doctor/src/DiagnosticSelection.php b/src/doctor/src/DiagnosticSelection.php new file mode 100644 index 0000000000..69841cec24 --- /dev/null +++ b/src/doctor/src/DiagnosticSelection.php @@ -0,0 +1,169 @@ + + */ + protected readonly array $only; + + /** + * The normalized selectors diagnostics must not match. + * + * @var list + */ + protected readonly array $except; + + /** + * The normalized only constraints that must all match. + * + * @var list> + */ + protected readonly array $onlyConstraints; + + /** + * Create a new diagnostic selection instance. + * + * @param iterable $only + * @param iterable $except + * @param list> $onlyConstraints + */ + public function __construct( + iterable $only = [], + iterable $except = [], + array $onlyConstraints = [], + ) { + $this->only = self::normalize($only); + $this->except = self::normalize($except); + + $constraints = []; + + foreach ($onlyConstraints as $constraint) { + $normalized = self::normalize($constraint); + + if ($normalized !== []) { + $constraints[] = $normalized; + } + } + + if ($constraints === [] && $this->only !== []) { + $constraints[] = $this->only; + } + + $this->onlyConstraints = $constraints; + } + + /** + * Create a new normalized diagnostic selection. + * + * @param iterable $only + * @param iterable $except + */ + public static function make(iterable $only = [], iterable $except = []): self + { + return new self(only: $only, except: $except); + } + + /** + * Add another selection constraint. + * + * @param iterable $only + * @param iterable $except + */ + public function constrain(iterable $only = [], iterable $except = []): self + { + $only = self::normalize($only); + $except = self::normalize($except); + + return new self( + only: $only !== [] ? $only : $this->only, + except: [...$this->except, ...$except], + onlyConstraints: [ + ...$this->onlyConstraints, + ...($only !== [] ? [$only] : []), + ], + ); + } + + /** + * Determine whether a diagnostic matches the selection. + */ + public function matches(Diagnostic $diagnostic): bool + { + foreach ($this->onlyConstraints as $criteria) { + if (! $this->matchesAny($criteria, $diagnostic)) { + return false; + } + } + + if ($this->except !== [] && $this->matchesAny($this->except, $diagnostic)) { + return false; + } + + return true; + } + + /** + * Normalize selection values. + * + * @param iterable $values + * @return list + */ + protected static function normalize(iterable $values): array + { + return array_values(collect($values) + ->flatMap(static fn (string $value): array => explode(',', $value)) + ->map(static fn (string $part): string => trim($part)) + ->filter(static fn (string $part): bool => $part !== '') + ->unique() + ->all()); + } + + /** + * Determine whether any criterion matches the diagnostic. + * + * @param list $criteria + */ + protected function matchesAny(array $criteria, Diagnostic $diagnostic): bool + { + foreach ($criteria as $criterion) { + if ($this->matchesCriterion($criterion, $diagnostic)) { + return true; + } + } + + return false; + } + + /** + * Determine whether a criterion matches the diagnostic. + */ + protected function matchesCriterion(string $criterion, Diagnostic $diagnostic): bool + { + return $criterion === $diagnostic::class + || $criterion === class_basename($diagnostic) + || $criterion === $diagnostic->group + || $this->matchesPackage($criterion, $diagnostic); + } + + /** + * Determine whether a criterion matches the diagnostic's package. + */ + protected function matchesPackage(string $criterion, Diagnostic $diagnostic): bool + { + $package = $diagnostic->package(); + + if ($package === null) { + return false; + } + + return $criterion === $package + || (str_ends_with($criterion, '/*') && str_starts_with($package, substr($criterion, 0, -1))); + } +} diff --git a/src/doctor/src/DiagnosticSource.php b/src/doctor/src/DiagnosticSource.php new file mode 100644 index 0000000000..63030f09b6 --- /dev/null +++ b/src/doctor/src/DiagnosticSource.php @@ -0,0 +1,233 @@ + + */ + private static array $sources = []; + + /** + * Create a new diagnostic source instance. + */ + public function __construct( + public readonly ?string $package, + public readonly ?string $file, + public readonly ?string $relativeFile, + public readonly bool $application, + ) { + } + + /** + * Resolve the source metadata for the given diagnostic class. + * + * @param class-string $class + */ + public static function resolve(string $class): self + { + if (! array_key_exists($class, self::$sources)) { + self::$sources[$class] = self::resolveSource($class); + } + + return self::$sources[$class]; + } + + /** + * Resolve the source metadata for the given diagnostic instance. + */ + public static function for(Diagnostic $diagnostic): self + { + $source = self::resolve($diagnostic::class); + + // A diagnostic may override package() to claim a different source... + if ($diagnostic->package() !== $source->package) { + return new self( + package: $diagnostic->package(), + file: $source->file, + relativeFile: $source->relativeFile, + application: $diagnostic->package() === null, + ); + } + + return $source; + } + + /** + * Get the user-facing source label. + */ + public function label(): string + { + return $this->package ?? 'application'; + } + + /** + * Resolve the source metadata for the given diagnostic class. + * + * @param class-string $class + */ + protected static function resolveSource(string $class): self + { + $file = self::classFile($class); + + if ($file === null) { + return new self( + package: null, + file: null, + relativeFile: null, + application: true, + ); + } + + $package = self::installedPackageForFile($file); + $path = $package !== null ? self::packagePath($package) : null; + + return new self( + package: $package, + file: $file, + relativeFile: $path !== null ? self::relativePath($path, $file) : null, + application: self::isApplicationSource($class, $package), + ); + } + + /** + * Resolve the file path for the given class. + * + * @param class-string $class + */ + protected static function classFile(string $class): ?string + { + try { + $file = (new ReflectionClass($class))->getFileName(); + } catch (Throwable) { + return null; + } + + if ($file === false) { + return null; + } + + $realPath = realpath($file); + + return $realPath === false ? null : $realPath; + } + + /** + * Determine whether a diagnostic class belongs to the application. + * + * @param class-string $class + */ + protected static function isApplicationSource(string $class, ?string $package): bool + { + if (str_starts_with($class, __NAMESPACE__ . '\Diagnostics\\')) { + return false; + } + + $root = self::rootPackageName(); + + return $root !== null && $package === $root; + } + + /** + * Find the installed Composer package that contains a file. + */ + protected static function installedPackageForFile(string $file): ?string + { + $match = null; + $matchLength = -1; + + foreach (InstalledVersions::getInstalledPackages() as $package) { + try { + $path = InstalledVersions::getInstallPath($package); + } catch (Throwable) { + continue; + } + + if (! is_string($path)) { + continue; + } + + $realPath = realpath($path); + + if ($realPath === false || ! self::contains($realPath, $file)) { + continue; + } + + $length = strlen($realPath); + + if ($length > $matchLength) { + $match = $package; + $matchLength = $length; + } + } + + return $match; + } + + /** + * Get the installation path for a Composer package. + */ + protected static function packagePath(string $package): ?string + { + try { + $path = InstalledVersions::getInstallPath($package); + } catch (Throwable) { + return null; + } + + if (! is_string($path)) { + return null; + } + + $realPath = realpath($path); + + return $realPath === false ? null : $realPath; + } + + /** + * Get the root Composer package name. + */ + protected static function rootPackageName(): ?string + { + try { + return InstalledVersions::getRootPackage()['name']; + } catch (Throwable) { + return null; + } + } + + /** + * Get the file path relative to its package root. + */ + protected static function relativePath(string $directory, string $file): string + { + if ($file === $directory) { + return basename($file); + } + + return substr($file, strlen($directory) + 1); + } + + /** + * Determine whether a directory contains a file. + */ + protected static function contains(string $directory, string $file): bool + { + return $file === $directory || str_starts_with($file, $directory . DIRECTORY_SEPARATOR); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + self::$sources = []; + } +} diff --git a/src/doctor/src/Diagnostics/ApplicationKeyIsSet.php b/src/doctor/src/Diagnostics/ApplicationKeyIsSet.php new file mode 100644 index 0000000000..b09cc32f5b --- /dev/null +++ b/src/doctor/src/Diagnostics/ApplicationKeyIsSet.php @@ -0,0 +1,107 @@ + + */ + protected function messages(): array + { + return [ + 'configured' => 'The application key is configured.', + 'missing' => Message::make( + summary: 'The application key is not configured.', + remediation: 'Generate an application key with `php artisan key:generate`.', + confirmation: 'Generate an application key?', + )->link(Link::docs('encryption')), + 'generated' => 'The application key was generated.', + 'generation-failed' => 'The application key could not be generated.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if ($this->keyIsConfigured()) { + return $this->pass('configured'); + } + + return $this->fail('missing')->fixable(EnvironmentMode::Local); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + if ($this->keyIsConfigured()) { + return $this->fixed('configured'); + } + + $previousKey = config('app.key'); + $path = app()->environmentFilePath(); + + if (! is_file($path) || ! is_writable($path)) { + return $this->fixFailed('generation-failed') + ->withDetails(sprintf('The application environment file [%s] could not be updated.', $path)); + } + + // key:generate can only replace an existing APP_KEY entry. This leaves + // existing values untouched while adding the blank entry it requires. + Env::writeVariable('APP_KEY', '', $path); + + if (Artisan::call('key:generate', ['--force' => true]) !== 0 || ! $this->keyIsConfigured()) { + return $this->fixFailed('generation-failed') + ->withDetails(Details::processOutput( + Artisan::output(), + '', + 'The key:generate command did not set an application key.', + )); + } + + $generatedKey = config()->string('app.key'); + config(['app.key' => $previousKey]); + + if (($failure = EnvironmentConfiguration::refresh('app.key', $generatedKey)) !== null) { + return $this->fixFailed('generation-failed')->withDetails($failure); + } + + return $this->fixed('generated'); + } + + /** + * Determine whether the application key is configured. + * + * @phpstan-impure + */ + private function keyIsConfigured(): bool + { + $key = config('app.key'); + + return is_string($key) && trim($key) !== ''; + } +} diff --git a/src/doctor/src/Diagnostics/BootstrapCacheMatchesEnvironment.php b/src/doctor/src/Diagnostics/BootstrapCacheMatchesEnvironment.php new file mode 100644 index 0000000000..08b556dc4f --- /dev/null +++ b/src/doctor/src/Diagnostics/BootstrapCacheMatchesEnvironment.php @@ -0,0 +1,112 @@ + + */ + protected function messages(): array + { + return [ + 'uncached-production' => Message::make( + summary: 'The application bootstrap files are not cached.', + remediation: 'Run `php artisan optimize` or `php artisan config:cache` during deployment.', + )->link(Link::docs('deployment', 'optimization')), + 'uncached-local' => 'The application bootstrap files are not cached.', + 'cached-production' => 'The application bootstrap files are cached.', + 'partially-cached-production' => Message::make( + summary: 'Cached bootstrap files: {cached}. Uncached bootstrap files: {uncached}.', + remediation: 'Run `php artisan optimize` during deployment to cache the remaining bootstrap files.', + )->link(Link::docs('deployment', 'optimization')), + 'cached-local' => Message::make( + summary: 'Cached bootstrap {files} detected: {cached}.', + remediation: 'If recent changes are not appearing, run `php artisan optimize:clear`.', + )->link(Link::docs('deployment', 'optimization')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $cached = $this->cachedFiles(); + + if ($cached === []) { + if (EnvironmentMode::current()->isProduction()) { + return $this->warn('uncached-production'); + } + + return $this->pass('uncached-local'); + } + + if (EnvironmentMode::current()->isProduction()) { + $uncached = array_values(array_diff(['config', 'events', 'routes'], $cached)); + + if ($uncached !== []) { + return $this->notice('partially-cached-production', [ + 'cached' => $this->formatCachedFiles($cached), + 'uncached' => $this->formatCachedFiles($uncached), + ]); + } + + return $this->pass('cached-production'); + } + + return $this->notice('cached-local', [ + 'files' => Str::plural('file', count($cached)), + 'cached' => $this->formatCachedFiles($cached), + ]); + } + + /** + * Get the cached bootstrap file types. + * + * @return list + */ + private function cachedFiles(): array + { + $cached = []; + + if (is_file(app()->getCachedConfigPath())) { + $cached[] = 'config'; + } + + if (is_file(app()->getCachedEventsPath())) { + $cached[] = 'events'; + } + + if (is_file(app()->getCachedRoutesPath())) { + $cached[] = 'routes'; + } + + return $cached; + } + + /** + * Format the cached bootstrap file types. + * + * @param list $cached + */ + private function formatCachedFiles(array $cached): string + { + return collect($cached)->join(', ', ' and '); + } +} diff --git a/src/doctor/src/Diagnostics/CacheStoreIsReachable.php b/src/doctor/src/Diagnostics/CacheStoreIsReachable.php new file mode 100644 index 0000000000..a6aeb0d493 --- /dev/null +++ b/src/doctor/src/Diagnostics/CacheStoreIsReachable.php @@ -0,0 +1,375 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application does not have a default cache store configured.', + 'unreachable' => Message::make( + summary: 'The application cannot reach the default cache store [{store}].', + remediation: 'Check CACHE_STORE and the backing cache service configuration.', + confirmation: 'Which cache store should the application use?', + )->link(Link::docs('cache', 'configuration')), + 'reachable' => 'The application can reach the default cache store.', + 'disabled' => 'The null cache store intentionally does not retain values.', + 'degraded' => Message::make( + summary: 'The default cache store [{store}] can write and read, but its failover capacity is degraded.', + remediation: 'Repair the unhealthy backing stores and check cache invalidation before relying on recovered stores.', + )->link(Link::docs('cache', 'cache-failover')), + 'switched' => 'The default cache store was switched to [{store}].', + 'switch-failed' => 'The default cache store could not be switched.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $store = Configured::string('cache.default'); + + if ($store === null) { + return $this->skip('not-configured'); + } + + if ($store === 'null' || Configured::string("cache.stores.{$store}.driver") === 'null') { + return $this->skip('disabled'); + } + + $failure = null; + $cleaned = true; + $alternatives = []; + + try { + $alternatives = $this->failoverStores($store); + $cleaned = $this->probe($store); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $failure = $exception; + } + + $unhealthy = []; + + foreach ($alternatives as $alternative) { + try { + if (! $this->probe($alternative)) { + $unhealthy[] = "[{$alternative}]: The probe key could not be removed."; + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $unhealthy[] = "[{$alternative}]: {$exception->getMessage()}"; + } + } + + if ($failure !== null || (! $cleaned && $unhealthy === [])) { + $details = [$failure?->getMessage() ?? 'The probe key could not be removed.', ...$unhealthy]; + $result = $this->fail('unreachable', ['store' => $store])->withDetails(implode("\n", $details)); + + // Building the option list probes candidate stores, so production + // failures skip the work the fixable(Local) scope would discard. + if (! EnvironmentMode::current()->isProduction() && ($options = $this->fixOptions($store)) !== []) { + $result->fixable(EnvironmentMode::Local)->fixOptions( + $options, + decline: sprintf('Keep %s (repair it manually)', Str::headline($store)), + ); + } + + return $result; + } + + if ($unhealthy !== []) { + if (! $cleaned) { + $unhealthy[] = 'Cleanup could not be confirmed for the configured cache store. Stale values may reappear when backing stores recover.'; + } + + return $this->warn('degraded', ['store' => $store])->withDetails(implode("\n", $unhealthy)); + } + + return $this->pass('reachable'); + } + + /** + * Fix the diagnostic by switching the application to the chosen store. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + /** @var string $option */ + $path = app()->environmentFilePath(); + + if (! is_file($path) || ! is_writable($path)) { + return $this->fixFailed('switch-failed') + ->withDetails(sprintf('The application environment file [%s] could not be updated.', $path)); + } + + if ($option === 'database' && $this->cacheTableIsMissing()) { + $failure = $this->createCacheTable(); + + if ($failure !== null) { + return $failure; + } + } + + try { + $this->ensureDatabaseTablesExist($option); + + if (! $this->probe($option)) { + return $this->fixFailed('switch-failed')->withDetails('The probe key could not be removed.'); + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fixFailed('switch-failed') + ->withDetails($e->getMessage()); + } + + Env::writeVariable('CACHE_STORE', $option, $path, overwrite: true); + + if (($failure = EnvironmentConfiguration::refresh('cache.default', $option)) !== null) { + return $this->fixFailed('switch-failed')->withDetails($failure); + } + + return $this->fixed('switched', ['store' => $option]); + } + + /** + * Get the viable alternative stores for the fix, keyed by store name. + * + * A candidate must be configured, use a different driver than the failing + * store, and survive a probe. The database store is also offered when its + * connection works but both default tables are missing, since the fix + * creates the tables before switching. + * + * @return array + */ + private function fixOptions(string $failing): array + { + $failingDriver = Configured::string(sprintf('cache.stores.%s.driver', $failing)); + + $options = []; + + foreach (['file' => 'File', 'database' => 'Database', 'redis' => 'Redis'] as $store => $label) { + $driver = Configured::string(sprintf('cache.stores.%s.driver', $store)); + + if ($driver === null || $driver === $failingDriver) { + continue; + } + + $needsTables = $store === 'database' && $this->cacheTableIsMissing(); + + try { + $this->ensureDatabaseTablesExist($store); + + if (! $this->probe($store)) { + continue; + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + if (! $needsTables) { + continue; + } + } + + $options[$store] = $needsTables + ? 'Database (creates cache tables and runs all pending migrations)' + : $label; + } + + return $options; + } + + /** + * Determine whether both tables in the default database cache layout are missing. + */ + private function cacheTableIsMissing(): bool + { + try { + $configuration = config('cache.stores.database'); + $defaultConnection = DB::getDefaultConnection(); + + if (! is_array($configuration) + || ($configuration['driver'] ?? null) !== 'database' + || ($configuration['table'] ?? null) !== CacheTableCommand::TABLE + || ($configuration['lock_table'] ?? DatabaseStore::DEFAULT_LOCK_TABLE) !== DatabaseStore::DEFAULT_LOCK_TABLE + || ($configuration['connection'] ?? $defaultConnection) !== $defaultConnection + || (($configuration['lock_connection'] ?? null) ?: $defaultConnection) !== $defaultConnection) { + return false; + } + + $schema = Schema::connection($defaultConnection); + + return ! $schema->hasTable(CacheTableCommand::TABLE) + && ! $schema->hasTable(DatabaseStore::DEFAULT_LOCK_TABLE); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return false; + } + } + + /** + * Ensure a database candidate has both its data and lock tables. + */ + private function ensureDatabaseTablesExist(string $store): void + { + $configuration = config()->array("cache.stores.{$store}"); + + if ($configuration['driver'] !== 'database') { + return; + } + + $connection = $configuration['connection'] ?? null; + $lockConnection = ($configuration['lock_connection'] ?? null) ?: $connection; + $lockTable = $configuration['lock_table'] ?? DatabaseStore::DEFAULT_LOCK_TABLE; + + if (! Schema::connection($connection)->hasTable($configuration['table']) + || ! Schema::connection($lockConnection)->hasTable($lockTable)) { + throw new RuntimeException('The database cache requires both its data and lock tables. Repair the missing tables before selecting this store.'); + } + } + + /** + * Create the database store's cache and lock tables. + */ + private function createCacheTable(): ?FixResult + { + foreach (['make:cache-table' => [], 'migrate' => ['--force' => true]] as $command => $parameters) { + if (Artisan::call($command, $parameters) !== 0) { + return $this->fixFailed('switch-failed') + ->withDetails(trim(Artisan::output())); + } + } + + return null; + } + + /** + * Probe a cache store with a short-lived key. + */ + private function probe(string $store): bool + { + $repository = Cache::store($store); + $key = $this->key(); + $failure = null; + $cleaned = false; + + try { + if (! $repository->put($key, true, 10)) { + throw new RuntimeException('The cache store did not accept the probe value.'); + } + + if ($repository->get($key) !== true) { + throw new RuntimeException('The cache store did not return the probe value.'); + } + } catch (Throwable $exception) { + $failure = $exception; + } finally { + try { + $cleaned = $repository->forget($key); + } catch (CanceledException $exception) { + if (! $failure instanceof CanceledException) { + $failure = $exception; + } + } catch (Throwable $exception) { + $failure ??= $exception; + } + } + + if ($failure !== null) { + throw $failure; + } + + return $cleaned; + } + + /** + * Find failover alternatives without separately probing ordinary stack layers. + * + * @param list $seen + * @return list + */ + private function failoverStores(string $store, array $seen = []): array + { + if (in_array($store, $seen, true)) { + return []; + } + + $driver = Configured::string("cache.stores.{$store}.driver"); + + if (! in_array($driver, ['stack', 'failover'], true)) { + return []; + } + + $stores = config()->array("cache.stores.{$store}.stores"); + $alternatives = []; + + foreach ($stores as $name => $options) { + $nested = $driver === 'stack' && is_array($options) ? $name : $options; + + if ($driver === 'stack') { + $nested = $nested instanceof UnitEnum ? (string) enum_value($nested) : $nested; + $nested ??= config()->string('cache.default'); + } + + if ($nested === 'null' || Configured::string("cache.stores.{$nested}.driver") === 'null') { + continue; + } + + if ($driver === 'failover') { + $alternatives[] = $nested; + } + + array_push($alternatives, ...$this->failoverStores($nested, [...$seen, $store])); + } + + return array_values(array_unique($alternatives)); + } + + /** + * Get a temporary cache key. + */ + private function key(): string + { + return 'hypervel-doctor:' . str_replace('.', '', uniqid('', true)); + } +} diff --git a/src/doctor/src/Diagnostics/ComposerAuditPasses.php b/src/doctor/src/Diagnostics/ComposerAuditPasses.php new file mode 100644 index 0000000000..51b10a1728 --- /dev/null +++ b/src/doctor/src/Diagnostics/ComposerAuditPasses.php @@ -0,0 +1,120 @@ + + */ + protected function messages(): array + { + return [ + 'lock-missing' => 'The application does not have a composer.lock file.', + 'vulnerable' => Message::make( + summary: 'Composer audit found vulnerable dependencies.', + remediation: 'Update or replace the vulnerable packages reported by `composer audit`.', + ), + 'audit-failed' => Message::make( + summary: 'Composer audit could not be completed.', + remediation: 'Run `composer audit` locally and resolve the reported issue.', + ), + 'policy-failed' => Message::make( + summary: 'Composer audit found dependencies that fail the configured policy.', + remediation: 'Run `composer audit` and resolve the reported policy findings.', + ), + 'clean' => 'Composer audit did not find vulnerable dependencies.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (! is_file(base_path('composer.lock'))) { + return $this->skip('lock-missing'); + } + + $process = Process::path(base_path())->timeout(10)->run([ + ...Composer::command(), + 'audit', + '--format=json', + '--no-interaction', + ]); + + $report = json_decode($process->output(), true); + $completed = is_array($report) && is_array($report['advisories'] ?? null); + $advisories = 0; + + foreach ($completed ? $report['advisories'] : [] as $packageAdvisories) { + $advisories += count($packageAdvisories); + } + + if ($advisories > 0) { + return $this->fail('vulnerable') + ->withDetails(sprintf( + '%d security %s %s reported.', + $advisories, + Str::plural('advisory', $advisories), + $advisories === 1 ? 'was' : 'were', + )); + } + + if (! $process->successful()) { + if ($completed) { + return $this->fail('policy-failed') + ->withDetails(Details::bullets($this->policyDetails($report))); + } + + return $this->fail('audit-failed') + ->withDetails(Details::processOutput( + $process->output(), + $process->errorOutput(), + 'Composer exited without audit details.', + )); + } + + return $this->pass('clean'); + } + + /** + * Describe the non-advisory findings reported by Composer. + * + * @return list + */ + private function policyDetails(array $report): array + { + $details = []; + + foreach ($report['abandoned'] ?? [] as $package => $replacement) { + $details[] = "{$package} is abandoned." + . ($replacement === null ? '' : " Suggested replacement: {$replacement}."); + } + + foreach ($report['filter'] ?? [] as $package => $entries) { + foreach ($entries as $entry) { + $details[] = "{$package} matches filter list [{$entry['listName']}]." + . (isset($entry['reason']) ? " {$entry['reason']}" : ''); + } + } + + return $details; + } +} diff --git a/src/doctor/src/Diagnostics/ComposerAutoloadIsValid.php b/src/doctor/src/Diagnostics/ComposerAutoloadIsValid.php new file mode 100644 index 0000000000..8bd116d2ad --- /dev/null +++ b/src/doctor/src/Diagnostics/ComposerAutoloadIsValid.php @@ -0,0 +1,97 @@ + + */ + protected function messages(): array + { + return [ + 'composer-missing' => 'The application does not have a composer.json file.', + 'dependencies-missing' => 'Composer dependencies are not installed.', + 'valid' => 'Composer can generate optimized autoload files.', + 'invalid' => Message::make( + summary: 'Composer cannot generate valid autoload files.', + remediation: 'Regenerate Composer autoload files with `composer dump-autoload`.', + confirmation: 'Regenerate Composer autoload files using `composer dump-autoload`?', + ), + 'regeneration-failed' => 'Composer autoload files could not be regenerated.', + 'regenerated' => 'Composer autoload files were regenerated.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (! is_file(base_path('composer.json'))) { + return $this->skip('composer-missing'); + } + + if (! is_file(app(PackageManifest::class)->vendorPath . '/autoload.php')) { + return $this->skip('dependencies-missing'); + } + + $process = Process::path(base_path())->run([ + ...Composer::command(), + 'dump-autoload', + '--dry-run', + '--no-scripts', + '--optimize', + '--strict-psr', + '--strict-ambiguous', + '--no-interaction', + ]); + + if ($process->successful()) { + return $this->pass('valid'); + } + + return $this->fail('invalid') + ->fixable() + ->withDetails(trim($process->errorOutput() !== '' ? $process->errorOutput() : $process->output())); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + $process = Process::path(base_path())->forever()->run([ + ...Composer::command(), + 'dump-autoload', + '--optimize', + '--strict-psr', + '--strict-ambiguous', + '--no-interaction', + ]); + + if (! $process->successful()) { + return $this->fixFailed('regeneration-failed') + ->withDetails(trim($process->errorOutput() !== '' ? $process->errorOutput() : $process->output())); + } + + return $this->fixed('regenerated'); + } +} diff --git a/src/doctor/src/Diagnostics/ComposerLockIsFresh.php b/src/doctor/src/Diagnostics/ComposerLockIsFresh.php new file mode 100644 index 0000000000..3f8906d346 --- /dev/null +++ b/src/doctor/src/Diagnostics/ComposerLockIsFresh.php @@ -0,0 +1,189 @@ + + */ + protected function messages(): array + { + return [ + 'composer-missing' => 'The application does not have a composer.json file.', + 'lock-missing' => Message::make( + summary: 'The application does not have a composer.lock file.', + remediation: 'Run `composer install` to generate a composer.lock file.', + confirmation: 'Generate composer.lock and install dependencies using `composer install`?', + ), + 'fresh' => 'composer.lock is present and fresh.', + 'stale' => Message::make( + summary: 'composer.lock is out of date with composer.json.', + remediation: 'Run `composer update --lock` to refresh the lock file.', + confirmation: 'Refresh composer.lock using `composer update --lock`?', + ), + 'constraint-mismatch' => Message::make( + summary: 'composer.lock does not satisfy the package constraints in composer.json.', + remediation: 'Run `composer update {packages}` to lock versions that satisfy composer.json.', + confirmation: 'Update {packages} using `composer update {packages}`?', + ), + 'inspection-failed' => Message::make( + summary: 'Composer could not verify composer.lock freshness.', + remediation: 'Run `composer validate --check-lock` and resolve the reported Composer errors.', + ), + 'fix-failed' => 'composer.lock could not be repaired.', + 'fixed' => 'composer.lock was repaired.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (! is_file(base_path('composer.json'))) { + return $this->skip('composer-missing'); + } + + if (! is_file(base_path('composer.lock'))) { + return $this->fail('lock-missing')->fixable(EnvironmentMode::Local); + } + + $process = Process::path(base_path())->run([ + ...Composer::command(), + 'validate', + '--check-lock', + '--no-check-publish', + '--no-check-all', + '--no-interaction', + '--no-ansi', + ]); + + if ($process->successful()) { + return $this->pass('fresh'); + } + + $details = Details::processOutput( + $process->output(), + $process->errorOutput(), + 'Composer exited without lock file details.', + ); + + if (($packages = $this->mismatchedPackages($details)) !== []) { + return $this->fail('constraint-mismatch', ['packages' => implode(' ', $packages)]) + ->fixable(EnvironmentMode::Local) + ->withContext('packages', $packages) + ->withDetails($this->lockFileErrors($details) ?? $details); + } + + if ($this->reportsStaleLock($details)) { + return $this->fail('stale') + ->fixable(EnvironmentMode::Local) + ->withDetails($this->lockFileErrors($details) ?? $details); + } + + return $this->fail('inspection-failed') + ->withDetails($details); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + $arguments = match ($result->code) { + 'composer-lock-is-fresh.lock-missing' => ['install'], + 'composer-lock-is-fresh.stale' => ['update', '--lock'], + 'composer-lock-is-fresh.constraint-mismatch' => $this->constraintUpdateArguments($result), + default => throw new LogicException("Unexpected composer.lock result [{$result->code}]."), + }; + + $process = Process::path(base_path())->forever()->run([ + ...Composer::command(), + ...$arguments, + '--no-interaction', + ]); + + if (! $process->successful()) { + return $this->fixFailed('fix-failed') + ->withDetails(Details::processOutput( + $process->output(), + $process->errorOutput(), + 'Composer exited without lock file repair details.', + )); + } + + return $this->fixed('fixed'); + } + + /** + * Get the targeted Composer update arguments for constraint mismatches. + * + * @return list + */ + private function constraintUpdateArguments(DiagnosticResult $result): array + { + /** @var list $packages */ + $packages = $result->context['packages']; + + return ['update', ...$packages]; + } + + /** + * Determine whether Composer reported a lock file freshness problem. + */ + private function reportsStaleLock(string $details): bool + { + return str_contains($details, '# Lock file errors') + || str_contains($details, 'lock file is not up to date'); + } + + /** + * Extract the factual lock file error lines from Composer's validation output. + * + * Composer intersperses its own remediation advice and documentation links + * with the errors; only the "- " bulleted error lines state what is wrong. + */ + private function lockFileErrors(string $details): ?string + { + $errors = str($details) + ->explode(PHP_EOL) + ->map(static fn (string $line): string => trim($line)) + ->filter(static fn (string $line): bool => str_starts_with($line, '- ')) + ->map(static fn (string $line): string => (string) str($line)->before(', it is recommended')->finish('.')) + ->implode(PHP_EOL); + + return $errors === '' ? null : $errors; + } + + /** + * Get the packages whose locked versions no longer satisfy composer.json. + * + * @return list + */ + private function mismatchedPackages(string $details): array + { + preg_match_all('/Required (?:dev )?package "([^"]+)"/', $details, $matches); + + return array_values(array_unique($matches[1])); + } +} diff --git a/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php b/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php new file mode 100644 index 0000000000..c8c3883506 --- /dev/null +++ b/src/doctor/src/Diagnostics/ConfigurationCanBeCached.php @@ -0,0 +1,90 @@ + + */ + protected function messages(): array + { + return [ + 'cannot-cache' => Message::make( + summary: 'The application configuration cannot be cached.', + remediation: 'Remove closures and other non-serializable values from the configuration files before running `php artisan config:cache`.', + )->link(Link::docs('configuration', 'configuration-caching')), + 'can-cache' => 'The application configuration can be cached.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + try { + $this->cacheConfiguration(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('cannot-cache') + ->withDetails($e->getMessage()); + } + + return $this->pass('can-cache'); + } + + /** + * Validate the current configuration's exported PHP representation. + */ + private function cacheConfiguration(): void + { + try { + eval('return ' . var_export(config()->all(), true) . ';'); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + throw $this->serializationException($e); + } + } + + /** + * Build the most specific serialization exception available. + */ + private function serializationException(Throwable $previous): Throwable + { + foreach (Arr::dot(config()->all()) as $key => $value) { + try { + eval('return ' . var_export($value, true) . ';'); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + return new LogicException( + sprintf('The configuration value at [%s] is not serializable: %s', $key, $exception->getMessage()), + 0, + $previous, + ); + } + } + + return new LogicException('The configuration files are not serializable: ' . $previous->getMessage(), 0, $previous); + } +} diff --git a/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php b/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php new file mode 100644 index 0000000000..2b361e91d3 --- /dev/null +++ b/src/doctor/src/Diagnostics/ConfigurationFilesCanBeLoaded.php @@ -0,0 +1,67 @@ + + */ + protected function messages(): array + { + return [ + 'missing' => 'The application does not have configuration files.', + 'load-failed' => Message::make( + summary: 'A configuration file could not be loaded.', + )->link(Link::docs('configuration')), + 'loaded' => 'Configuration files can be loaded.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $path = config_path(); + + if (! is_dir($path)) { + return $this->skip('missing'); + } + + $files = Finder::create()->files()->name('*.php')->in($path)->sortByName(); + + if (! $files->hasResults()) { + return $this->skip('missing'); + } + + foreach ($files as $file) { + try { + (fn (): mixed => require $file->getPathname())(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('load-failed') + ->withDetails(sprintf('%s: %s', $file->getRelativePathname(), $e->getMessage())); + } + } + + return $this->pass('loaded'); + } +} diff --git a/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php b/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php new file mode 100644 index 0000000000..33c203345c --- /dev/null +++ b/src/doctor/src/Diagnostics/DatabaseConnectionIsReachable.php @@ -0,0 +1,223 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application does not have a default database connection configured.', + 'connection-missing' => 'The default database connection [{connection}] is not configured.', + 'unreachable' => Message::make( + summary: 'The application cannot connect to the default database connection [{connection}].', + remediation: 'Check DB_CONNECTION and the database credentials in your environment file.', + )->link(Link::docs('database', 'configuration')), + 'reachable' => 'The application can connect to the default database connection.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $connection = Configured::string('database.default'); + + if ($connection === null) { + return $this->skip('not-configured'); + } + + $name = ConnectionName::parse($connection); + $configuration = $this->configuration($name->base); + + if ($configuration === null) { + return $this->skip('connection-missing', ['connection' => $connection]); + } + + try { + $failures = $this->probe($name, $configuration); + + if ($failures !== []) { + return $this->fail('unreachable', ['connection' => $connection]) + ->withDetails(implode(PHP_EOL, $failures)); + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('unreachable', ['connection' => $connection]) + ->withDetails($e->getMessage()); + } + + return $this->pass('reachable'); + } + + /** + * Probe every record the selected connection can use. + * + * @param array $configuration + * @return list + */ + private function probe(ConnectionName $name, array $configuration): array + { + /** @var ConnectionFactory $factory */ + $factory = app('db.factory'); + $failures = []; + + foreach ($this->probeConfigurations($factory, $name, $configuration) as $label => [$selected, $read]) { + try { + $this->probeConnection($factory, $name->base, $selected, $read); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $failures[] = ($label === '' ? '' : "[{$label}] ") . $exception->getMessage(); + } + } + + return $failures; + } + + /** + * Select records without changing the factory's extension resolution path. + * + * @return iterable, bool}> + */ + private function probeConfigurations(ConnectionFactory $factory, ConnectionName $name, array $configuration): iterable + { + $extension = $factory->getExtension($configuration, $name->base); + + if ($extension !== null || ! $factory->hasReadConfig($configuration)) { + if ($name->role !== null) { + $configuration[Connection::READ_WRITE_TYPE_CONFIG_KEY] = $name->role; + } + + yield '' => [$extension !== null ? $configuration : $this->withConnectionTimeout($configuration), false]; + + return; + } + + foreach ($name->role !== null ? [$name->role] : [ConnectionName::WRITE, ConnectionName::READ] as $role) { + $records = isset($configuration[$role][0]) ? $configuration[$role] : [$configuration[$role]]; + + foreach (array_values($records) as $index => $record) { + $selected = array_replace($configuration, [$role => $record]); + $effective = $role === ConnectionName::READ + ? $factory->configForRead($selected) + : $factory->configForWrite($selected); + + if ($name->isRead()) { + $selected = $factory->getExtension($effective, $name->base) !== null + ? $effective + : $this->withConnectionTimeout($effective); + } else { + // Base split connections resolve read connectors without another extension lookup. + $selected[$role]['options'] = $this->withConnectionTimeout($effective)['options']; + } + + if ($name->role !== null) { + $selected[Connection::READ_WRITE_TYPE_CONFIG_KEY] = $name->role; + } + + yield $role . ' record ' . ($index + 1) => [$selected, $name->role === null && $role === ConnectionName::READ]; + } + } + } + + /** + * Open and release a transient connection through its owning factory. + */ + private function probeConnection(ConnectionFactory $factory, string $name, array $configuration, bool $read): void + { + $database = $factory->make($configuration, $name); + + $failure = null; + + try { + if ($read) { + /** @var PdoConnection $database */ + $database->getReadPdo(); + } else { + $database->getServerVersion(); + } + } catch (Throwable $exception) { + $failure = $exception; + } finally { + try { + $database->disconnect(); + } catch (CanceledException $exception) { + if (! $failure instanceof CanceledException) { + $failure = $exception; + } + } catch (Throwable $exception) { + $failure ??= $exception; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * Get the default database connection configuration. + * + * @return null|array + */ + private function configuration(string $connection): ?array + { + $configuration = config("database.connections.{$connection}"); + + if (! is_array($configuration)) { + return null; + } + + return app('db.factory')->parseConfig($configuration, $connection); + } + + /** + * Add conservative connection timeouts to the transient probe configuration. + * + * @param array $configuration + * @return array + */ + private function withConnectionTimeout(array $configuration): array + { + $options = $configuration['options'] ?? []; + + if (! is_array($options)) { + return $configuration; + } + + $options[PDO::ATTR_TIMEOUT] ??= self::CONNECTION_TIMEOUT_SECONDS; + + $configuration['options'] = $options; + + return $configuration; + } +} diff --git a/src/doctor/src/Diagnostics/DebugModeMatchesEnvironment.php b/src/doctor/src/Diagnostics/DebugModeMatchesEnvironment.php new file mode 100644 index 0000000000..8b19cfffde --- /dev/null +++ b/src/doctor/src/Diagnostics/DebugModeMatchesEnvironment.php @@ -0,0 +1,87 @@ + + */ + protected function messages(): array + { + return [ + 'enabled-in-production' => Message::make( + summary: 'Debug mode is enabled in production.', + remediation: 'Set APP_DEBUG=false in production.', + confirmation: 'Set APP_DEBUG=false in the application environment file?', + )->link(Link::docs('configuration', 'debug-mode')), + 'matches' => 'Debug mode matches the application environment.', + 'already-disabled' => 'Debug mode is already disabled.', + 'disable-failed' => 'Debug mode could not be disabled in the application environment file.', + 'disabled' => 'Debug mode was disabled in the application environment file.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if ($this->debugIsEnabledInProduction()) { + return $this->fail('enabled-in-production')->fixable(); + } + + return $this->pass('matches'); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + if (! $this->debugIsEnabledInProduction()) { + return $this->fixed('already-disabled'); + } + + $path = app()->environmentFilePath(); + + if (! is_file($path) || ! is_writable($path)) { + return $this->fixFailed('disable-failed') + ->withDetails(sprintf('The application environment file [%s] could not be updated.', $path)); + } + + Env::writeVariable('APP_DEBUG', 'false', $path, overwrite: true); + + if (($failure = EnvironmentConfiguration::refresh('app.debug', false)) !== null) { + return $this->fixFailed('disable-failed')->withDetails($failure); + } + + return $this->fixed('disabled'); + } + + /** + * Determine whether debug mode is enabled in production. + */ + private function debugIsEnabledInProduction(): bool + { + return config()->boolean('app.debug') && EnvironmentMode::current()->isProduction(); + } +} diff --git a/src/doctor/src/Diagnostics/EnvironmentFileExists.php b/src/doctor/src/Diagnostics/EnvironmentFileExists.php new file mode 100644 index 0000000000..1192da22ba --- /dev/null +++ b/src/doctor/src/Diagnostics/EnvironmentFileExists.php @@ -0,0 +1,106 @@ + + */ + protected function messages(): array + { + return [ + 'exists' => 'The selected environment file [{path}] exists.', + 'external' => 'The selected environment file [{path}] is absent; production may supply environment variables externally.', + 'missing' => Message::make( + summary: 'The selected environment file [{path}] does not exist.', + remediation: 'Create [{path}] with the application\'s environment variables.', + confirmation: 'Create an empty environment file at [{path}]?', + )->link(Link::docs('configuration', 'environment-configuration')), + 'missing-with-example' => Message::make( + summary: 'The selected environment file [{path}] does not exist.', + remediation: 'Copy [{example}] to [{path}], then review the copied values.', + confirmation: 'Copy [{example}] to [{path}]?', + )->link(Link::docs('configuration', 'environment-configuration')), + 'already-exists' => 'The environment file [{path}] already exists.', + 'creation-failed' => 'The environment file [{path}] could not be created from [{example}].', + 'created' => 'The environment file [{path}] was created from [{example}].', + 'empty-creation-failed' => 'The empty environment file [{path}] could not be created.', + 'empty-created' => 'An empty environment file [{path}] was created.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $path = app()->environmentFilePath(); + $example = app()->environmentPath() . '/.env.example'; + + if (is_file($path)) { + return $this->pass('exists', ['path' => $path]); + } + + if (EnvironmentMode::current()->isProduction()) { + return $this->skip('external', ['path' => $path]); + } + + if (is_file($example)) { + return $this->fail('missing-with-example', ['path' => $path, 'example' => $example])->fixable(EnvironmentMode::Local); + } + + return $this->fail('missing', ['path' => $path])->fixable(EnvironmentMode::Local); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + $target = app()->environmentFilePath(); + $example = app()->environmentPath() . '/.env.example'; + $replace = ['path' => $target, 'example' => $example]; + + if (is_file($target)) { + return $this->fixed('already-exists', $replace); + } + + if (file_exists($target) || is_link($target)) { + return $this->fixFailed('creation-failed', $replace); + } + + if (! is_file($example)) { + if (File::put($target, '') === false) { + return $this->fixFailed('empty-creation-failed', $replace); + } + + return $this->fixed('empty-created', $replace) + ->withDetails('Add the application environment variables and run `php artisan key:generate`.'); + } + + if (! @copy($example, $target)) { + return $this->fixFailed('creation-failed', $replace); + } + + return $this->fixed('created', $replace) + ->withDetails(sprintf('Review [%s] and run `php artisan key:generate` if APP_KEY is empty.', $target)); + } +} diff --git a/src/doctor/src/Diagnostics/EnvironmentFileIsGitIgnored.php b/src/doctor/src/Diagnostics/EnvironmentFileIsGitIgnored.php new file mode 100644 index 0000000000..9298b2270c --- /dev/null +++ b/src/doctor/src/Diagnostics/EnvironmentFileIsGitIgnored.php @@ -0,0 +1,172 @@ + + */ + protected function messages(): array + { + return [ + 'gitignore-missing' => Message::make( + summary: 'The environment file [{path}] is not ignored and has no adjacent .gitignore file.', + remediation: 'Create [{gitignore}] with a rule for the selected environment file so secrets are not committed.', + confirmation: 'Create [{gitignore}] to ignore [{path}]?', + )->link(Link::docs('configuration', 'environment-configuration')), + 'git-missing' => 'Git is unavailable; ignoring of [{path}] could not be checked.', + 'not-repository' => 'The environment file [{path}] is outside a Git work tree.', + 'inspection-failed' => 'Git could not inspect the environment file [{path}].', + 'tracked' => Message::make( + summary: 'The environment file [{path}] is tracked by Git.', + remediation: 'Remove it from Git tracking with `git rm --cached -- `, add an ignore rule, and rotate any exposed secrets.', + )->link(Link::docs('configuration', 'environment-configuration')), + 'ignored' => 'The environment file [{path}] is ignored by Git.', + 'not-ignored' => Message::make( + summary: 'The environment file [{path}] is not ignored by Git.', + remediation: 'Add a rule for [{path}] to [{gitignore}] so secrets are not committed.', + confirmation: 'Add an ignore rule for [{path}] to [{gitignore}]?', + )->link(Link::docs('configuration', 'environment-configuration')), + 'already-ignored' => 'The environment file [{path}] is already ignored by Git.', + 'update-failed' => 'Git protection could not be established for [{path}].', + 'updated' => 'The environment file [{path}] is now ignored by Git.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $path = app()->environmentFilePath(); + $replace = ['path' => $path, 'gitignore' => $this->gitignorePath()]; + $process = $this->git(['rev-parse', '--is-inside-work-tree']); + + if ($process->exitCode() === 127) { + return $this->skip('git-missing', $replace); + } + + if (($process->exitCode() === 128 && str_contains($process->errorOutput(), 'not a git repository')) + || ($process->successful() && trim($process->output()) === 'false')) { + return $this->skip('not-repository', $replace); + } + + if (! $process->successful() || trim($process->output()) !== 'true') { + return $this->error('inspection-failed', $replace)->withDetails(Details::processOutput( + $process->output(), + $process->errorOutput(), + 'Git did not confirm work-tree membership.', + )); + } + + $process = $this->git(['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', basename($path)]); + + if ($process->successful()) { + return $this->fail('tracked', $replace); + } + + if ($process->exitCode() !== 1) { + return $this->error('inspection-failed', $replace)->withDetails(Details::processOutput( + $process->output(), + $process->errorOutput(), + 'Git could not determine whether the file is tracked.', + )); + } + + // Tracking was checked literally above; ignore Git's separate index pathspec matching. + $process = $this->git(['check-ignore', '--no-index', '-q', '--', basename($path)]); + + if ($process->successful()) { + return $this->pass('ignored', $replace); + } + + if ($process->exitCode() !== 1) { + return $this->error('inspection-failed', $replace)->withDetails(Details::processOutput( + $process->output(), + $process->errorOutput(), + 'Git could not determine whether the file is ignored.', + )); + } + + return $this->fail(File::exists($this->gitignorePath()) ? 'not-ignored' : 'gitignore-missing', $replace) + ->fixable(EnvironmentMode::Local); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + $path = $this->gitignorePath(); + $replace = ['path' => app()->environmentFilePath()]; + $current = $this->check(); + + if ($current->code === $this->code('ignored')) { + return $this->fixed('already-ignored', $replace); + } + + if (! in_array($current->code, [$this->code('not-ignored'), $this->code('gitignore-missing')], true)) { + return $this->fixFailed('update-failed', $replace)->withDetails($current->details ?? $current->summary); + } + + $contents = File::exists($path) ? File::get($path) : ''; + $prefix = $contents !== '' && ! str_ends_with($contents, PHP_EOL) ? PHP_EOL : ''; + $pattern = '/' . strtr(basename(app()->environmentFilePath()), [ + '\\' => '\\\\', '*' => '\*', '?' => '\?', '[' => '\[', + ]); + + if (File::put($path, $contents . $prefix . $pattern . PHP_EOL) === false) { + return $this->fixFailed('update-failed', $replace); + } + + $updated = $this->check(); + + if ($updated->code !== $this->code('ignored')) { + return $this->fixFailed('update-failed', $replace)->withDetails($updated->details ?? $updated->summary); + } + + return $this->fixed('updated', $replace); + } + + /** + * Run Git beside the selected environment file. + * + * @param list $arguments + */ + private function git(array $arguments): ProcessResult + { + return Process::path(dirname(app()->environmentFilePath())) + ->env(['LC_ALL' => 'C']) + ->timeout(10) + ->run(['git', ...$arguments]); + } + + /** + * Get the application .gitignore path. + */ + private function gitignorePath(): string + { + return dirname(app()->environmentFilePath()) . '/.gitignore'; + } +} diff --git a/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php b/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php new file mode 100644 index 0000000000..915bd9988e --- /dev/null +++ b/src/doctor/src/Diagnostics/FilesystemDisksAreReachable.php @@ -0,0 +1,115 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application does not have a default filesystem disk configured.', + 'disk-missing' => 'The default filesystem disk [{disk}] is not configured.', + 'unreachable' => Message::make( + summary: 'The application cannot reach the default filesystem disk [{disk}].', + remediation: 'Check filesystem disk roots, credentials, and network access.', + )->link(Link::docs('filesystem', 'configuration')), + 'reachable' => 'The application can reach the default filesystem disk.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $disk = Configured::string('filesystems.default'); + + if ($disk === null) { + return $this->skip('not-configured'); + } + + $configuration = $this->configuration($disk); + + if ($configuration === null) { + return $this->skip('disk-missing', ['disk' => $disk]); + } + + try { + $this->probe($disk, $configuration); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('unreachable', ['disk' => $disk]) + ->withDetails($e->getMessage()); + } + + return $this->pass('reachable'); + } + + /** + * Probe a filesystem disk. + * + * @param array $configuration + */ + private function probe(string $disk, array $configuration): void + { + if (($configuration['driver'] ?? null) === 'local') { + $this->probeLocalDisk($configuration); + + return; + } + + Storage::disk($disk)->exists('.hypervel-doctor'); + } + + /** + * Probe a local filesystem disk. + * + * @param array $configuration + */ + private function probeLocalDisk(array $configuration): void + { + $root = $configuration['root'] ?? null; + + if (! is_string($root) || $root === '') { + throw new RuntimeException('The local disk root is not configured.'); + } + + if (! is_dir($root) || ! is_readable($root)) { + throw new RuntimeException(sprintf('The local disk root [%s] is not readable.', $root)); + } + } + + /** + * Get the default filesystem disk configuration. + * + * @return null|array + */ + private function configuration(string $disk): ?array + { + $configuration = config("filesystems.disks.{$disk}"); + + return is_array($configuration) ? $configuration : null; + } +} diff --git a/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php b/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php new file mode 100644 index 0000000000..8b3000e946 --- /dev/null +++ b/src/doctor/src/Diagnostics/MigrationsAreUpToDate.php @@ -0,0 +1,119 @@ + + */ + protected function messages(): array + { + return [ + 'no-files' => 'The application does not have migration files.', + 'repository-missing' => Message::make( + summary: 'The migrations table does not exist.', + remediation: 'Run `php artisan migrate` to create the migrations table and apply the migrations.', + confirmation: 'Create the migrations table and apply pending migrations using `php artisan migrate`?', + )->link(Link::docs('migrations', 'running-migrations')), + 'inspection-failed' => 'The application could not inspect database migrations.', + 'current' => 'Database migrations are current.', + 'pending' => Message::make( + summary: 'Database migrations are pending.', + remediation: 'Run `php artisan migrate` to apply the pending migrations.', + confirmation: 'Apply pending migrations using `php artisan migrate`?', + )->link(Link::docs('migrations', 'running-migrations')), + 'migration-failed' => 'Database migrations could not be applied.', + 'migrated' => 'Database migrations were applied.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + /** @var Migrator $migrator */ + $migrator = app('migrator'); + + try { + $files = $migrator->getMigrationFiles(array_values(array_unique([ + ...$migrator->paths(), + database_path('migrations'), + ]))); + + if ($files === []) { + return $this->pass('no-files'); + } + + return $migrator->usingConnection(null, function () use ($migrator, $files): DiagnosticResult { + if (! $migrator->repositoryExists()) { + return $this->fail('repository-missing')->fixable(EnvironmentMode::Local); + } + + $pending = $migrator->getPendingMigrations( + $files, + $migrator->getRepository()->getRan(), + ); + + if ($pending === []) { + return $this->pass('current'); + } + + return $this->fail('pending') + ->fixable(EnvironmentMode::Local) + ->withDetails(Details::bullets(array_map($migrator->getMigrationName(...), $pending))); + }); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('inspection-failed') + ->withDetails($e->getMessage()); + } + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + try { + if (Artisan::call('migrate', ['--no-interaction' => true]) !== 0) { + return $this->fixFailed('migration-failed') + ->withDetails(Details::processOutput( + Artisan::output(), + '', + 'The migrate command exited without output.', + )); + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + return $this->fixFailed('migration-failed') + ->withDetails($exception->getMessage()); + } + + return $this->fixed('migrated'); + } +} diff --git a/src/doctor/src/Diagnostics/PhpVersionSatisfiesComposerRequirement.php b/src/doctor/src/Diagnostics/PhpVersionSatisfiesComposerRequirement.php new file mode 100644 index 0000000000..7e8c9df94a --- /dev/null +++ b/src/doctor/src/Diagnostics/PhpVersionSatisfiesComposerRequirement.php @@ -0,0 +1,93 @@ + + */ + protected function messages(): array + { + return [ + 'constraint-missing' => 'No applicable Composer PHP version constraints were found.', + 'satisfied' => 'The current PHP version satisfies the application and installed packages.', + 'unsatisfied' => Message::make( + summary: 'PHP {version} does not satisfy the application or installed package requirements.', + remediation: 'Use a compatible PHP version or install dependency versions that support this PHP version.', + )->link(Link::docs('deployment', 'server-requirements')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $requirements = app(InstalledPackages::class)->platformRequirements($this->composer)['php']['requirements'] ?? []; + + if ($requirements === []) { + return $this->skip('constraint-missing'); + } + + $version = $this->phpVersion(); + $parser = new VersionParser; + + try { + $normalized = $parser->normalize($version); + } catch (UnexpectedValueException) { + // Composer strips distribution suffixes only when the version is otherwise invalid. + $normalized = $parser->normalize(preg_replace('#^([^~+-]+).*$#', '$1', $version)); + } + + $unsatisfied = []; + + foreach ($requirements as $package => $constraint) { + if (! Semver::satisfies($normalized, $constraint)) { + $unsatisfied[] = "- {$package} requires php {$constraint}."; + } + } + + if ($unsatisfied === []) { + return $this->pass('satisfied'); + } + + return $this->fail('unsatisfied', [ + 'version' => $version, + ])->withDetails(implode(PHP_EOL, $unsatisfied)); + } + + /** + * Get the running PHP interpreter's version. + */ + protected function phpVersion(): string + { + return PHP_VERSION; + } +} diff --git a/src/doctor/src/Diagnostics/PublicStorageLinkExists.php b/src/doctor/src/Diagnostics/PublicStorageLinkExists.php new file mode 100644 index 0000000000..9cec635d82 --- /dev/null +++ b/src/doctor/src/Diagnostics/PublicStorageLinkExists.php @@ -0,0 +1,222 @@ + + */ + protected function messages(): array + { + return [ + 'not-default-local-path' => 'The public disk does not use a local filesystem.', + 'not-configured' => 'No filesystem links are configured for the public disk root.', + 'unused' => 'The public storage directory does not contain public files.', + 'linked' => 'The public storage link exists.', + 'missing' => Message::make( + summary: 'The public storage link does not exist.', + remediation: 'Create the public storage link with `php artisan storage:link`.', + confirmation: 'Create the public storage link using `php artisan storage:link`?', + )->link(Link::docs('filesystem', 'the-public-disk')), + 'wrong-link' => Message::make( + summary: 'A public storage symlink is dangling or points to the wrong target.', + remediation: 'Review the configured links, then explicitly run `php artisan storage:link --force` to replace incorrect symlinks.', + )->link(Link::docs('filesystem', 'the-public-disk')), + 'conflicting-path' => Message::make( + summary: 'A file or directory occupies a configured public storage link path.', + remediation: 'Move the conflicting path aside, then run `php artisan storage:link`.', + )->link(Link::docs('filesystem', 'the-public-disk')), + 'creation-failed' => 'The public storage link could not be created.', + 'created' => 'The public storage link was created.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (! $this->expectsPublicStorageLink()) { + return $this->skip('not-default-local-path'); + } + + $links = $this->expectedLinks(); + + if ($links === []) { + return $this->skip('not-configured'); + } + + if ($this->publicDiskIsUnused()) { + return $this->skip('unused'); + } + + $failures = $this->inspectLinks($links); + + if ($failures === []) { + return $this->pass('linked'); + } + + $code = in_array('conflicting-path', $failures, true) ? 'conflicting-path' + : (in_array('wrong-link', $failures, true) ? 'wrong-link' : 'missing'); + $result = $this->fail($code)->withDetails($this->linkDetails($links, $failures)); + + return $code === 'missing' ? $result->fixable() : $result; + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + $links = $this->expectedLinks(); + $failures = $this->inspectLinks($links); + + if ($links === [] || array_diff($failures, ['missing']) !== []) { + return $this->fixFailed('creation-failed')->withDetails( + $links === [] ? 'No links target the public disk root.' : $this->linkDetails($links, $failures), + ); + } + + try { + $exitCode = Artisan::call('storage:link'); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + return $this->fixFailed('creation-failed')->withDetails($exception->getMessage()); + } + + $failures = $this->inspectLinks($links); + + if ($exitCode !== 0 || $failures !== []) { + return $this->fixFailed('creation-failed') + ->withDetails(trim($this->linkDetails($links, $failures) . PHP_EOL . Details::processOutput( + Artisan::output(), + '', + 'The storage:link command exited without output.', + ))); + } + + return $this->fixed('created'); + } + + /** + * Determine whether the public disk can be exposed through local links. + */ + private function expectsPublicStorageLink(): bool + { + $disk = config('filesystems.disks.public'); + + return is_array($disk) && ($disk['driver'] ?? null) === 'local'; + } + + /** + * Get configured links targeting the public disk root. + * + * @return array + */ + private function expectedLinks(): array + { + $root = $this->normalize(config()->string('filesystems.disks.public.root')); + + return array_filter( + config()->array('filesystems.links'), + fn (string $target): bool => $this->normalize($target) === $root, + ); + } + + /** + * Inspect each configured path without changing existing files or links. + * + * @param array $links + * @return array + */ + private function inspectLinks(array $links): array + { + $failures = []; + + foreach ($links as $link => $target) { + clearstatcache(true, $link); + + if (realpath($link) === $this->normalize($target)) { + continue; + } + + $failures[$link] = ! file_exists($link) && ! is_link($link) ? 'missing' + : (is_link($link) ? 'wrong-link' : 'conflicting-path'); + } + + return $failures; + } + + /** + * Describe the failed paths and their expected targets. + * + * @param array $links + * @param array $failures + */ + private function linkDetails(array $links, array $failures): string + { + $details = []; + + foreach ($failures as $link => $failure) { + $details[] = sprintf('[%s] → [%s]: %s.', $link, $links[$link], match ($failure) { + 'missing' => 'missing link', + 'wrong-link' => 'dangling or incorrect symlink; review storage:link --force', + default => 'conflicting file or directory; move it aside before storage:link', + }); + } + + return implode(PHP_EOL, $details); + } + + /** + * Determine whether the public disk contains only placeholder files. + */ + private function publicDiskIsUnused(): bool + { + $path = config()->string('filesystems.disks.public.root'); + + if (! is_dir($path)) { + return true; + } + + foreach (new FilesystemIterator($path) as $file) { + if (! in_array($file->getFilename(), ['.gitignore', '.gitkeep'], true)) { + return false; + } + } + + return true; + } + + /** + * Normalize a filesystem path for target comparison. + */ + private function normalize(string $path): string + { + $realPath = realpath($path); + + return $realPath === false ? rtrim($path, DIRECTORY_SEPARATOR) : $realPath; + } +} diff --git a/src/doctor/src/Diagnostics/QueueConnectionIsAsynchronous.php b/src/doctor/src/Diagnostics/QueueConnectionIsAsynchronous.php new file mode 100644 index 0000000000..b6ff65660b --- /dev/null +++ b/src/doctor/src/Diagnostics/QueueConnectionIsAsynchronous.php @@ -0,0 +1,86 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application does not have a default queue connection configured.', + 'sync-production' => Message::make( + summary: 'Queued jobs run synchronously in production.', + remediation: 'Set QUEUE_CONNECTION to a background queue driver such as database, redis, sqs, or beanstalkd.', + )->link(Link::docs('queues', 'connections-vs-queues')), + 'sync-local' => 'Queued jobs run synchronously.', + 'invalid' => 'The configured queue has no usable concrete connection.', + 'disabled' => 'The null queue driver intentionally discards jobs.', + 'in-process' => 'Queued jobs run asynchronously in application coroutines; no queue worker is required.', + 'async' => 'Queued jobs are processed asynchronously.', + 'async-local' => Message::make( + summary: 'Queued jobs are processed asynchronously.', + remediation: 'If jobs are not being processed, make sure a queue worker is running via `php artisan queue:work`.', + )->link(Link::docs('queues', 'running-the-queue-worker')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $connection = config('queue.default'); + + if (! is_string($connection) || $connection === '') { + return $this->skip('not-configured'); + } + + $connections = ActiveDrivers::queueConnections($connection); + + if ($connections === []) { + return $this->fail('invalid'); + } + + $driver = $connections[0] === 'null' ? 'null' : config()->string("queue.connections.{$connections[0]}.driver"); + + if ($driver === 'null') { + return $this->skip('disabled'); + } + + if ($driver === 'sync' && EnvironmentMode::current()->isProduction()) { + return $this->warn('sync-production'); + } + + if ($driver === 'sync') { + return $this->pass('sync-local'); + } + + if (in_array($driver, ['background', 'deferred'], true)) { + return $this->pass('in-process'); + } + + if (EnvironmentMode::current()->isProduction()) { + return $this->pass('async'); + } + + return $this->notice('async-local'); + } +} diff --git a/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php b/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php new file mode 100644 index 0000000000..b1a5b816e4 --- /dev/null +++ b/src/doctor/src/Diagnostics/QueueConnectionIsReachable.php @@ -0,0 +1,172 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application does not have a default queue connection configured.', + 'unreachable' => Message::make( + summary: 'The application cannot reach the default queue connection [{connection}].', + remediation: 'Check QUEUE_CONNECTION and the backing queue service configuration.', + )->link(Link::docs('queues')), + 'reachable' => 'The application can reach the default queue connection.', + 'degraded' => Message::make( + summary: 'The queue has reachable connections but reduced failover capacity.', + remediation: 'Repair the unhealthy queue connections listed below.', + )->link(Link::docs('queues')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $connection = Configured::string('queue.default'); + + if ($connection === null) { + return $this->skip('not-configured'); + } + + try { + $connections = ActiveDrivers::queueConnections($connection); + + if ($connections === []) { + throw new RuntimeException('The configured queue has no concrete connections to probe.'); + } + + $unhealthy = []; + + foreach ($connections as $name) { + try { + $this->probe($name, $this->configuration($name)); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $unhealthy[] = "[{$name}]: {$exception->getMessage()}"; + } + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('unreachable', ['connection' => $connection]) + ->withDetails($e->getMessage()); + } + + if (count($unhealthy) === count($connections)) { + return $this->fail('unreachable', ['connection' => $connection])->withDetails(implode(PHP_EOL, $unhealthy)); + } + + if ($unhealthy !== []) { + return $this->warn('degraded')->withDetails(implode(PHP_EOL, $unhealthy)); + } + + return $this->pass('reachable'); + } + + /** + * Probe the configured queue connection. + * + * @param array $configuration + */ + private function probe(string $connection, array $configuration): void + { + match ($configuration['driver'] ?? null) { + 'sync' => null, + 'database' => $this->probeDatabaseQueue($configuration), + 'redis' => $this->probeRedisQueue($configuration), + 'sqs', 'beanstalkd' => Queue::connection($connection)->size(), + default => Queue::connection($connection), + }; + } + + /** + * Probe a database-backed queue. + * + * @param array $configuration + */ + private function probeDatabaseQueue(array $configuration): void + { + $table = $configuration['table'] ?? null; + + if (! is_string($table) || $table === '') { + throw new RuntimeException('The database queue table is not configured.'); + } + + if (! $this->schema($configuration)->hasTable($table)) { + throw new RuntimeException(sprintf('The [%s] queue table does not exist.', $table)); + } + } + + /** + * Probe a Redis-backed queue. + * + * @param array $configuration + */ + private function probeRedisQueue(array $configuration): void + { + $connection = $configuration['connection'] ?? 'default'; + + Redis::connection($connection)->ping(); + } + + /** + * Get the queue connection configuration. + * + * @return array + */ + private function configuration(string $connection): array + { + if ($connection === 'null') { + return ['driver' => 'null']; + } + + $configuration = config("queue.connections.{$connection}"); + + if (! is_array($configuration)) { + throw new RuntimeException(sprintf('The [%s] queue connection is not configured.', $connection)); + } + + return $configuration; + } + + /** + * Get the schema builder for the queue connection. + * + * @param array $configuration + */ + private function schema(array $configuration): Builder + { + $connection = $configuration['connection'] ?? null; + + return Schema::connection($connection); + } +} diff --git a/src/doctor/src/Diagnostics/RecommendedPhpExtensionsAreLoaded.php b/src/doctor/src/Diagnostics/RecommendedPhpExtensionsAreLoaded.php new file mode 100644 index 0000000000..de48b159c2 --- /dev/null +++ b/src/doctor/src/Diagnostics/RecommendedPhpExtensionsAreLoaded.php @@ -0,0 +1,93 @@ + + */ + protected function messages(): array + { + return [ + 'none-declared' => 'No Composer-suggested PHP extensions are declared.', + 'installed' => 'All Composer-suggested PHP extensions are installed.', + 'missing' => Message::make( + summary: 'Some Composer-suggested PHP extensions are missing.', + remediation: 'Install the suggested extensions if the application uses the features that depend on them.', + ), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $extensions = $this->composer->suggestedExtensions(); + + if ($extensions === []) { + return $this->pass('none-declared'); + } + + $missing = $this->missingExtensions($extensions); + + if ($missing === []) { + return $this->pass('installed'); + } + + return $this->warn('missing') + ->withDetails($this->formatExtensions($missing)); + } + + /** + * Get the missing PHP extensions. + * + * @param list $extensions + * @return list + */ + private function missingExtensions(array $extensions): array + { + $loaded = app(InstalledPackages::class)->loadedExtensions(); + + return array_values(array_filter( + $extensions, + static fn (string $extension): bool => ! isset($loaded['ext-' . $extension]), + )); + } + + /** + * Format extension names for diagnostic details. + * + * @param list $extensions + */ + private function formatExtensions(array $extensions): string + { + return implode(PHP_EOL, array_map( + static fn (string $extension): string => '- ext-' . $extension, + $extensions, + )); + } +} diff --git a/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php b/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php new file mode 100644 index 0000000000..7cb232eeed --- /dev/null +++ b/src/doctor/src/Diagnostics/RedisConnectionsAreReachable.php @@ -0,0 +1,70 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application is not using Redis-backed cache, queue, or session storage.', + 'unreachable' => Message::make( + summary: 'The application cannot reach every active Redis connection.', + remediation: 'Check Redis host, port, credentials, and client configuration.', + )->link(Link::docs('redis', 'configuration')), + 'reachable' => 'The application can reach every active Redis connection.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $connections = ActiveDrivers::redisConnections(); + + if ($connections === []) { + return $this->skip('not-configured'); + } + + $failures = []; + + foreach ($connections as $connection) { + try { + Redis::connection($connection)->ping(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + $failures[$connection] = $e->getMessage(); + } + } + + if ($failures !== []) { + return $this->fail('unreachable') + ->withDetails(Details::failures($failures)); + } + + return $this->pass('reachable'); + } +} diff --git a/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php b/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php new file mode 100644 index 0000000000..f39a54518f --- /dev/null +++ b/src/doctor/src/Diagnostics/RedisSupportsConfiguredFeatures.php @@ -0,0 +1,128 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application is not using Redis-backed cache, queue, or session storage.', + 'unsupported' => Message::make( + summary: 'The Redis client or server does not meet the configured feature requirements.', + remediation: 'Install compatible phpredis and Redis/Valkey versions, or disable the features that require them.', + )->link(Link::docs('redis', 'configuration')), + 'supported' => 'Redis meets the configured client and feature version requirements.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (ActiveDrivers::redisConnections() === []) { + return $this->skip('not-configured'); + } + + $version = phpversion('redis') ?: '0'; + + if (version_compare($version, RedisRequirements::MINIMUM_PHPREDIS_VERSION, '<')) { + return $this->fail('unsupported')->withDetails(sprintf( + 'Redis requires phpredis >= %s (installed: %s).', + RedisRequirements::MINIMUM_PHPREDIS_VERSION, + $version, + )); + } + + $connections = $this->featureConnections(); + + if ($connections === []) { + return $this->pass('supported'); + } + + if (($error = RedisRequirements::hashFieldExpirationClientError($version)) !== null) { + return $this->fail('unsupported')->withDetails($error); + } + + $failures = []; + + foreach ($connections as $connection) { + try { + $error = RedisRequirements::hashFieldExpirationServerError(Redis::connection($connection)->info('server')); + + if ($error !== null) { + $failures[$connection] = $error; + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + $failures[$connection] = $e->getMessage(); + } + } + + if ($failures !== []) { + return $this->fail('unsupported') + ->withDetails(Details::failures($failures)); + } + + return $this->pass('supported'); + } + + /** + * Get data connections that need hash field expiration. + * + * @return list + */ + private function featureConnections(): array + { + $connections = []; + $default = Configured::string('cache.default'); + + foreach ($default === null ? [] : ActiveDrivers::cacheStores($default) as $store) { + $configuration = config("cache.stores.{$store}"); + + if (! is_array($configuration) || ($configuration['driver'] ?? null) !== 'redis') { + continue; + } + + $mode = $configuration['tag_mode'] ?? TagMode::All; + $mode = $mode instanceof TagMode ? $mode : TagMode::fromConfig($mode); + + if ($mode === TagMode::Any) { + $connections[] = $configuration['connection'] ?? 'default'; + } + } + + if (config('session.driver') === 'redis' && config()->boolean('session.track_user_sessions')) { + $connections[] = Configured::string('session.connection', SessionManager::DEFAULT_REDIS_CONNECTION); + } + + return array_values(array_unique($connections)); + } +} diff --git a/src/doctor/src/Diagnostics/RequiredConfigurationValuesAreSet.php b/src/doctor/src/Diagnostics/RequiredConfigurationValuesAreSet.php new file mode 100644 index 0000000000..6ddb298bff --- /dev/null +++ b/src/doctor/src/Diagnostics/RequiredConfigurationValuesAreSet.php @@ -0,0 +1,312 @@ + + */ + protected function messages(): array + { + return [ + 'set' => 'Every configuration value required by the active drivers is set.', + 'missing' => Message::make( + summary: 'Some configuration values required by the active drivers are not set.', + remediation: 'Set the missing values, typically by defining their environment variables in .env or the deployment environment.', + )->link(Link::docs('configuration', 'environment-configuration')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $missing = $this->missingValues(); + + if ($missing === []) { + return $this->pass('set'); + } + + return $this->fail('missing') + ->withDetails($this->formatMissingValues($missing)); + } + + /** + * Get required configuration keys without a value, mapped to the feature that needs them. + * + * @return array + */ + private function missingValues(): array + { + $required = $this->requiredValues(); + + return [ + ...array_intersect_key($required, array_flip(Configured::missing(array_keys($required)))), + ...$this->missingMercureValues(), + ...$this->missingMailValues(), + ]; + } + + /** + * Get configuration keys required by the active drivers, mapped to the feature that needs them. + * + * @return array + */ + private function requiredValues(): array + { + return [ + ...$this->broadcastingValues(), + ...$this->filesystemValues(), + ...$this->loggingValues(), + ...$this->queueValues(), + ]; + } + + /** + * Get configuration keys required by the default broadcasting connection. + * + * @return array + */ + private function broadcastingValues(): array + { + $connection = Configured::string('broadcasting.default', 'null'); + $driver = Configured::string("broadcasting.connections.{$connection}.driver", $connection); + + $keys = match ($driver) { + 'ably' => ['key'], + 'pusher', 'reverb' => ['app_id', 'key', 'secret'], + default => [], + }; + + return $this->values("broadcasting.connections.{$connection}", $keys, "{$driver} broadcaster"); + } + + /** + * Get missing Mercure values using the factory's effective credential choices. + * + * @return array + */ + private function missingMercureValues(): array + { + $connection = Configured::string('broadcasting.default', 'null'); + $prefix = "broadcasting.connections.{$connection}"; + + if (Configured::string("{$prefix}.driver", $connection) !== 'mercure') { + return []; + } + + $keys = ["{$prefix}.url"]; + + foreach (['publish', 'subscribe'] as $side) { + $keys[] = config("{$prefix}.{$side}_secret") ? "{$prefix}.{$side}_secret" : "{$prefix}.secret"; + } + + $missing = []; + + foreach ($keys as $key) { + $value = config($key); + + if (! $value || blank($value)) { + $missing[$key] = 'mercure broadcaster'; + } + } + + return $missing; + } + + /** + * Get configuration keys required by the default filesystem disk. + * + * @return array + */ + private function filesystemValues(): array + { + $disk = Configured::string('filesystems.default', 'local'); + + if (Configured::string("filesystems.disks.{$disk}.driver", $disk) !== 's3') { + return []; + } + + return $this->values("filesystems.disks.{$disk}", ['bucket', 'region'], 's3 filesystem disk'); + } + + /** + * Get configuration keys required by the active log channels. + * + * @return array + */ + private function loggingValues(): array + { + $values = []; + + foreach (ActiveDrivers::logChannels(Configured::string('logging.default', 'stack')) as $channel) { + $values = [...$values, ...$this->logChannelValues($channel)]; + } + + return $values; + } + + /** + * Get configuration keys required by a log channel. + * + * @return array + */ + private function logChannelValues(string $channel): array + { + $driver = Configured::string("logging.channels.{$channel}.driver", $channel); + + if ($driver === 'slack') { + return $this->values("logging.channels.{$channel}", ['url'], 'slack log channel'); + } + + if ($driver === 'monolog' && config("logging.channels.{$channel}.handler") === SyslogUdpHandler::class) { + $prefix = "logging.channels.{$channel}"; + $with = config("{$prefix}.with") ?? []; + $handlerWith = config("{$prefix}.handler_with") ?? []; + $values = []; + + foreach (['host', 'port'] as $key) { + $source = array_key_exists($key, $handlerWith) || ! array_key_exists($key, $with) + ? 'handler_with' + : 'with'; + $values["{$prefix}.{$source}.{$key}"] = "{$channel} log channel"; + } + + return $values; + } + + return []; + } + + /** + * Get missing values from the active mailers' effective configuration. + * + * @return array + */ + private function missingMailValues(): array + { + $values = []; + + foreach (ActiveDrivers::mailers(Configured::string('mail.default', 'log')) as $mailer) { + $values = [...$values, ...$this->missingMailerValues($mailer)]; + } + + return $values; + } + + /** + * Get missing values using the mail transport factory's credential choices. + * + * @return array + */ + private function missingMailerValues(string $mailer): array + { + $configuration = ActiveDrivers::mailerConfiguration($mailer); + $transport = $configuration['transport'] ?? $mailer; + $prefix = "mail.mailers.{$mailer}"; + $keys = match ($transport) { + 'smtp' => ['host'], + 'sendmail' => ['path'], + 'postmark', 'resend' => ['key'], + 'cloudflare' => ['account_id', 'key'], + 'mailgun' => ['secret', 'domain'], + default => [], + }; + + if ($transport === 'mailgun' && ! isset($configuration['secret'])) { + $configuration = config()->array('services.mailgun', []); + $prefix = 'services.mailgun'; + } + + $missing = []; + + foreach ($keys as $key) { + $value = $configuration[$key] ?? null; + $location = "{$prefix}.{$key}"; + + if ($value === null && in_array($transport, ['postmark', 'resend', 'cloudflare'], true)) { + $location = "services.{$transport}.{$key}"; + $value = config($location); + } + + if (blank($value)) { + $missing[$location] = "{$transport} mailer"; + } + } + + return $missing; + } + + /** + * Get configuration keys required by the active queue connections. + * + * @return array + */ + private function queueValues(): array + { + $connection = Configured::string('queue.default', 'database'); + $values = []; + + foreach (ActiveDrivers::queueConnections($connection) as $name) { + if (Configured::string("queue.connections.{$name}.driver", $name) !== 'sqs' + || config("queue.connections.{$name}.overflow.enabled") !== true + || config("queue.connections.{$name}.overflow.store") === null) { + continue; + } + + $values = [...$values, ...$this->values("queue.connections.{$name}.overflow", ['store'], 'sqs queue overflow')]; + } + + return $values; + } + + /** + * Map configuration keys under a prefix to the feature that requires them. + * + * @param list $keys + * @return array + */ + private function values(string $prefix, array $keys, string $feature): array + { + $values = []; + + foreach ($keys as $key) { + $values["{$prefix}.{$key}"] = $feature; + } + + return $values; + } + + /** + * Format missing configuration values. + * + * @param array $missing + */ + private function formatMissingValues(array $missing): string + { + return Details::bullets(array_map( + static fn (string $key, string $feature): string => sprintf('%s (%s)', $key, $feature), + array_keys($missing), + $missing, + )); + } +} diff --git a/src/doctor/src/Diagnostics/RequiredPhpExtensionsAreLoaded.php b/src/doctor/src/Diagnostics/RequiredPhpExtensionsAreLoaded.php new file mode 100644 index 0000000000..0b7f2f23f2 --- /dev/null +++ b/src/doctor/src/Diagnostics/RequiredPhpExtensionsAreLoaded.php @@ -0,0 +1,130 @@ + + */ + protected function messages(): array + { + return [ + 'composer-unreadable' => 'The application does not have a readable composer.json file.', + 'installed' => 'All Composer-required PHP extensions are satisfied by loaded extensions or installed providers.', + 'missing' => Message::make( + summary: 'Some Composer-required PHP extensions are missing or incompatible.', + remediation: 'Install compatible PHP extensions or providers for the PHP binary running Hypervel.', + ), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $packages = app(InstalledPackages::class); + $platform = $packages->platformRequirements($this->composer); + + if ($platform === [] && $this->composer->contents() === null) { + return $this->skip('composer-unreadable'); + } + + $loaded = $packages->loadedExtensions(); + $parser = new VersionParser; + $missing = []; + + foreach ($platform as $extension => $metadata) { + if (! str_starts_with($extension, 'ext-') || $metadata['requirements'] === []) { + continue; + } + + if ($this->satisfied($loaded[$extension] ?? null, $metadata['requirements'], $metadata['providers'], $parser)) { + continue; + } + + $requirements = []; + + foreach ($metadata['requirements'] as $package => $constraint) { + $requirements[] = "{$package} requires {$constraint}"; + } + + $missing[] = sprintf('- %s (%s): %s.', $extension, isset($loaded[$extension]) ? 'loaded ' . $loaded[$extension] : 'not loaded', implode('; ', $requirements)); + } + + if ($missing === []) { + return $this->pass('installed'); + } + + return $this->fail('missing') + ->withDetails(implode(PHP_EOL, $missing)); + } + + /** + * Determine whether a loaded extension or one provider satisfies every requirement. + * + * @param array $requirements + * @param array $providers + */ + private function satisfied(?string $version, array $requirements, array $providers, VersionParser $parser): bool + { + if ($version !== null) { + $version = $this->normalizeVersion($version, $parser); + + if (array_all($requirements, static fn (string $constraint): bool => Semver::satisfies($version, $constraint))) { + return true; + } + } + + $constraints = array_map($parser->parseConstraints(...), $requirements); + + foreach ($providers as $provider) { + $candidate = $parser->parseConstraints($provider); + + if (array_all($constraints, static fn (ConstraintInterface $constraint): bool => $candidate->matches($constraint))) { + return true; + } + } + + return false; + } + + /** + * Normalize extension versions using Composer's numeric fallback. + */ + private function normalizeVersion(string $version, VersionParser $parser): string + { + try { + return $parser->normalize($version); + } catch (UnexpectedValueException) { + return $parser->normalize(preg_match('/^(\d+\.\d+\.\d+(?:\.\d+)?)/', $version, $matches) === 1 ? $matches[1] : '0'); + } + } +} diff --git a/src/doctor/src/Diagnostics/ScheduledTasksRequireScheduler.php b/src/doctor/src/Diagnostics/ScheduledTasksRequireScheduler.php new file mode 100644 index 0000000000..ac6874bf0f --- /dev/null +++ b/src/doctor/src/Diagnostics/ScheduledTasksRequireScheduler.php @@ -0,0 +1,46 @@ + + */ + protected function messages(): array + { + return [ + 'no-tasks' => 'The application does not have scheduled tasks.', + 'tasks-registered' => Message::make( + summary: 'The application has scheduled tasks.', + remediation: 'Keep `php artisan schedule:run` running; supervise this long-running process in production.', + )->link(Link::docs('scheduling', 'running-the-scheduler')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + if (app(Schedule::class)->events() === []) { + return $this->pass('no-tasks'); + } + + return $this->notice('tasks-registered'); + } +} diff --git a/src/doctor/src/Diagnostics/SessionDriverIsReachable.php b/src/doctor/src/Diagnostics/SessionDriverIsReachable.php new file mode 100644 index 0000000000..fd1a774cfe --- /dev/null +++ b/src/doctor/src/Diagnostics/SessionDriverIsReachable.php @@ -0,0 +1,135 @@ + + */ + protected function messages(): array + { + return [ + 'not-configured' => 'The application does not have a default session driver configured.', + 'unreachable' => Message::make( + summary: 'The application cannot reach the default session driver [{driver}].', + remediation: 'Check SESSION_DRIVER and the backing session store configuration.', + )->link(Link::docs('session', 'configuration')), + 'reachable' => 'The application can reach the default session driver.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $driver = Configured::string('session.driver'); + + if ($driver === null) { + return $this->skip('not-configured'); + } + + try { + $this->probe($driver); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + return $this->fail('unreachable', ['driver' => $driver]) + ->withDetails($e->getMessage()); + } + + return $this->pass('reachable'); + } + + /** + * Probe the session driver. + */ + private function probe(string $driver): void + { + match ($driver) { + 'array', 'cookie' => null, + 'file', 'native' => $this->probeFileSessions(), + 'database' => $this->probeDatabaseSessions(), + 'redis' => $this->probeRedisSessions(), + default => $this->probeSessionManager(), + }; + } + + /** + * Probe file-backed sessions. + */ + private function probeFileSessions(): void + { + $path = Configured::string('session.files'); + + if ($path === null) { + throw new RuntimeException('The session file path is not configured.'); + } + + if (! is_dir($path) || ! is_writable($path)) { + throw new RuntimeException(sprintf('The session file path [%s] is not writable.', $path)); + } + } + + /** + * Probe database-backed sessions. + */ + private function probeDatabaseSessions(): void + { + $table = Configured::string('session.table'); + + if ($table === null) { + throw new RuntimeException('The database session table is not configured.'); + } + + if (! $this->schema()->hasTable($table)) { + throw new RuntimeException(sprintf('The [%s] session table does not exist.', $table)); + } + } + + /** + * Probe Redis-backed sessions. + */ + private function probeRedisSessions(): void + { + Redis::connection(Configured::string('session.connection', SessionManager::DEFAULT_REDIS_CONNECTION))->ping(); + } + + /** + * Get the schema builder for the session connection. + */ + private function schema(): Builder + { + return Schema::connection(Configured::string('session.connection')); + } + + /** + * Resolve custom session drivers through Hypervel's session manager. + */ + private function probeSessionManager(): void + { + app(SessionManager::class)->driver(); + } +} diff --git a/src/doctor/src/Diagnostics/SqliteDatabaseExists.php b/src/doctor/src/Diagnostics/SqliteDatabaseExists.php new file mode 100644 index 0000000000..433e197411 --- /dev/null +++ b/src/doctor/src/Diagnostics/SqliteDatabaseExists.php @@ -0,0 +1,135 @@ + + */ + protected function messages(): array + { + return [ + 'not-sqlite' => 'The default database connection is not SQLite.', + 'not-file' => 'The SQLite connection does not use a database file.', + 'uri' => 'The SQLite connection uses a URI; the connector manages its database path and creation mode.', + 'exists' => 'The SQLite database file exists.', + 'missing' => Message::make( + summary: 'The SQLite database file does not exist.', + remediation: 'Create the SQLite database file with `touch {database}`.', + confirmation: 'Create the SQLite database file?', + )->link(Link::docs('database', 'configuration')), + 'already-exists' => 'The SQLite database file already exists.', + 'creation-failed' => 'The SQLite database file could not be created.', + 'created' => 'The SQLite database file was created.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $connection = Configured::string('database.default'); + $name = $connection === null ? null : ConnectionName::parse($connection); + $configuration = $name === null ? null : config("database.connections.{$name->base}"); + + if (! is_array($configuration)) { + return $this->skip('not-sqlite'); + } + + /** @var ConnectionFactory $factory */ + $factory = app('db.factory'); + $configuration = $factory->parseConfig($configuration, $name->base); + + if ($factory->hasReadConfig($configuration) && $factory->getExtension($configuration, $name->base) === null) { + $configuration = $name->isRead() + ? $factory->configForRead($configuration) + : $factory->configForWrite($configuration); + } + + if (($configuration['driver'] ?? null) !== 'sqlite') { + return $this->skip('not-sqlite'); + } + + $database = $configuration['database'] ?? null; + + if (! is_string($database) || $database === '' || SQLiteDatabase::isInMemory($database)) { + return $this->skip('not-file'); + } + + if (SQLiteDatabase::isUri($database)) { + return $this->skip('uri'); + } + + if (! $this->isAbsolutePath($database)) { + $database = realpath($database) ?: base_path($database); + } + + if (is_file($database)) { + return $this->pass('exists') + ->withContext('database', $database); + } + + return $this->fail('missing', ['database' => $database]) + ->fixable() + ->withContext('database', $database); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + /** @var string $database */ + $database = $result->context['database']; + + if (is_file($database)) { + return $this->fixed('already-exists'); + } + + File::ensureDirectoryExists(dirname($database)); + + $handle = @fopen($database, 'x'); + + if ($handle === false) { + return is_file($database) + ? $this->fixed('already-exists') + : $this->fixFailed('creation-failed'); + } + + fclose($handle); + + return $this->fixed('created'); + } + + /** + * Determine whether the database path is absolute. + */ + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || str_starts_with($path, '\\') + || preg_match('/^[a-zA-Z]:[\\\\\/]/', $path) === 1; + } +} diff --git a/src/doctor/src/Diagnostics/StorageIsWritable.php b/src/doctor/src/Diagnostics/StorageIsWritable.php new file mode 100644 index 0000000000..27c14d8926 --- /dev/null +++ b/src/doctor/src/Diagnostics/StorageIsWritable.php @@ -0,0 +1,178 @@ + + */ + protected function messages(): array + { + return [ + 'writable' => 'The application storage directories are writable.', + 'not-writable' => Message::make( + summary: 'The application cannot write to every required storage directory.', + remediation: 'Ensure the listed storage and bootstrap cache directories exist and are writable by the PHP process.', + confirmation: 'Make the storage directories writable?', + ), + 'still-not-writable' => 'Some storage directories are still not writable.', + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + $failures = $this->failures(); + + if ($failures === []) { + return $this->pass('writable'); + } + + return $this->fail('not-writable') + ->fixable() + ->withDetails(Details::failures($failures)); + } + + /** + * Fix the diagnostic. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + $changed = []; + + foreach (array_keys($this->failures()) as $path) { + if (! is_dir($path)) { + if (! @mkdir($path, 0775, true) && ! is_dir($path)) { + throw new RuntimeException(sprintf('Unable to create [%s].', $path)); + } + + $changed[] = sprintf('Created %s.', $path); + } + + if (! $this->canWriteToDirectory($path) && ! @chmod($path, (fileperms($path) & 07777) | 0700)) { + throw new RuntimeException(sprintf('Unable to change permissions for [%s].', $path)); + } + } + + $failures = $this->failures(); + + if ($failures !== []) { + return $this->fixFailed('still-not-writable') + ->withDetails(Details::failures($failures)); + } + + if ($changed === []) { + return $this->fixed('writable'); + } + + return $this->fixed('writable') + ->withDetails(implode(PHP_EOL, $changed)); + } + + /** + * Get required storage and configured cache directories. + * + * @return list + */ + private function paths(): array + { + $application = app(); + $paths = [ + storage_path(), + storage_path('app'), + storage_path('framework'), + storage_path('framework/cache'), + storage_path('framework/sessions'), + storage_path('framework/views'), + storage_path('logs'), + dirname($application->getCachedConfigPath()), + dirname($application->getCachedRoutesPath()), + dirname($application->getCachedEventsPath()), + dirname($application->getCachedPackagesPath()), + ]; + + if (($path = Configured::string('view.compiled')) !== null) { + $paths[] = $path; + } + + if (in_array(config('session.driver'), ['file', 'native'], true) + && ($path = Configured::string('session.files')) !== null) { + $paths[] = $path; + } + + $default = Configured::string('cache.default'); + + foreach ($default === null ? [] : ActiveDrivers::cacheStores($default) as $store) { + if (config("cache.stores.{$store}.driver") === 'file' + && ($path = Configured::string("cache.stores.{$store}.path")) !== null) { + $paths[] = $path; + $paths[] = config("cache.stores.{$store}.lock_path") ?? $path; + } + } + + return array_values(array_unique($paths)); + } + + /** + * Get directories that cannot accept a temporary write. + * + * @return array + */ + private function failures(): array + { + $failures = []; + + foreach ($this->paths() as $path) { + if (! is_dir($path)) { + $failures[$path] = 'directory does not exist'; + + continue; + } + + if (! $this->canWriteToDirectory($path)) { + $failures[$path] = 'directory is not writable'; + } + } + + return $failures; + } + + /** + * Determine whether a temporary file can be created and removed. + */ + private function canWriteToDirectory(string $path): bool + { + $file = $path . DIRECTORY_SEPARATOR . '.doctor-write-test-' . str_replace('.', '', uniqid('', true)); + + $handle = @fopen($file, 'x'); + + if ($handle === false) { + return false; + } + + fclose($handle); + + return @unlink($file); + } +} diff --git a/src/doctor/src/Doctor.php b/src/doctor/src/Doctor.php new file mode 100644 index 0000000000..3f4adf1873 --- /dev/null +++ b/src/doctor/src/Doctor.php @@ -0,0 +1,268 @@ +, class-string> + */ + protected array $diagnostics = []; + + /** + * Create a new Doctor instance. + */ + public function __construct(protected Container $container) + { + } + + /** + * Register a diagnostic. + * + * Boot-only. The registry belongs to the shared Doctor service and affects subsequent runs. + * + * @param class-string $diagnostic + */ + public function diagnostic(string $diagnostic): self + { + if (! is_subclass_of($diagnostic, Diagnostic::class)) { + throw new InvalidArgumentException('Diagnostics must extend [' . Diagnostic::class . '].'); + } + + $this->diagnostics[$diagnostic] = $diagnostic; + + return $this; + } + + /** + * Register multiple diagnostics. + * + * Boot-only. The registry belongs to the shared Doctor service and affects subsequent runs. + * + * @param iterable $diagnostics + */ + public function diagnostics(iterable $diagnostics): self + { + foreach ($diagnostics as $diagnostic) { + $this->diagnostic($diagnostic); + } + + return $this; + } + + /** + * Get the registered diagnostic classes. + * + * @return list> + */ + public function registered(): array + { + return array_values($this->diagnostics); + } + + /** + * Determine whether any diagnostics are registered. + */ + public function hasDiagnostics(): bool + { + return $this->diagnostics !== []; + } + + /** + * Run the registered diagnostics. + */ + public function run(): DiagnosticReport + { + return $this->pendingRun()->run(); + } + + /** + * Begin a run constrained to diagnostics matching the given selectors. + * + * Selectors may reference a diagnostic class, class basename, group, or + * package name. + */ + public function only(string ...$selectors): PendingRun + { + return $this->pendingRun()->only(...$selectors); + } + + /** + * Begin a run that excludes diagnostics matching the given selectors. + */ + public function except(string ...$selectors): PendingRun + { + return $this->pendingRun()->except(...$selectors); + } + + /** + * Begin a run that stops after the first failing diagnostic. + */ + public function bail(bool $bail = true): PendingRun + { + return $this->pendingRun()->bail($bail); + } + + /** + * Begin a run that observes each diagnostic group through the given callback. + * + * @param Closure(string, GroupContinuation): void $callback + */ + public function observeUsing(Closure $callback): PendingRun + { + return $this->pendingRun()->observeUsing($callback); + } + + /** + * Begin a run that applies fixes approved by the given callback. + * + * @param Closure(DiagnosticOutcome): (bool|string) $callback + */ + public function fixUsing(Closure $callback): PendingRun + { + return $this->pendingRun()->fixUsing($callback); + } + + /** + * Begin a run that invokes the given callback when applied fixes require + * the diagnostics to be re-run. + * + * @param Closure(): void $callback + */ + public function beforeRerun(Closure $callback): PendingRun + { + return $this->pendingRun()->beforeRerun($callback); + } + + /** + * Begin a new pending diagnostic run. + */ + protected function pendingRun(): PendingRun + { + return new PendingRun($this, $this->defaultSelection()); + } + + /** + * Get the diagnostic selection configured for the application. + */ + protected function defaultSelection(): DiagnosticSelection + { + return DiagnosticSelection::make( + only: array_filter(config()->array('doctor.only'), is_string(...)), + except: array_filter(config()->array('doctor.except'), is_string(...)), + ); + } + + /** + * Run a single diagnostic. + */ + public function check(Diagnostic $diagnostic): DiagnosticOutcome + { + try { + $result = $diagnostic->check(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + $result = DiagnosticResult::error($e->getMessage()) + ->withContext('exception', $e::class); + } + + return new DiagnosticOutcome($diagnostic, $result); + } + + /** + * Run the fix for a diagnostic outcome. + */ + public function fix(DiagnosticOutcome $outcome, ?string $option = null): DiagnosticFixOutcome + { + $diagnostic = $outcome->diagnostic; + + if (! $diagnostic instanceof Fixable) { + throw new LogicException(sprintf('Diagnostic [%s] does not implement [%s].', $diagnostic::class, Fixable::class)); + } + + if (! $outcome->fixable()) { + throw new LogicException(sprintf('Diagnostic outcome [%s] is not fixable.', $outcome->result->code ?? $diagnostic::class)); + } + + $options = $outcome->result->fixOptions; + + if ($option === null && $options !== []) { + throw new LogicException(sprintf('Diagnostic outcome [%s] requires a fix option.', $outcome->result->code ?? $diagnostic::class)); + } + + if ($option !== null && $options === []) { + throw new LogicException(sprintf('Diagnostic outcome [%s] does not accept a fix option.', $outcome->result->code ?? $diagnostic::class)); + } + + if ($option !== null && ! array_key_exists($option, $options)) { + throw new LogicException(sprintf('Fix option [%s] is not declared for diagnostic outcome [%s].', $option, $outcome->result->code ?? $diagnostic::class)); + } + + try { + $result = $diagnostic->fix($outcome->result, $option); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $e) { + $result = FixResult::error( + sprintf('Failed to fix %s: %s', $diagnostic->name, $e->getMessage()), + )->withContext('exception', $e::class); + } + + return new DiagnosticFixOutcome($diagnostic, $result); + } + + /** + * Get the selected diagnostics grouped by diagnostic group. + * + * @return array> + */ + public function selectedByGroup(?DiagnosticSelection $selection = null): array + { + return collect($this->selected($selection)) + ->groupBy(static fn (Diagnostic $diagnostic): string => $diagnostic->group) + ->map(static fn (Collection $diagnostics): array => array_values($diagnostics->all())) + ->all(); + } + + /** + * Get the selected diagnostic instances in registration order. + * + * @return list + */ + protected function selected(?DiagnosticSelection $selection = null): array + { + $diagnostics = []; + + foreach ($this->diagnostics as $class) { + $diagnostic = $this->container->makeTransient($class); + + if ($selection === null || $selection->matches($diagnostic)) { + $diagnostics[] = $diagnostic; + } + } + + return $diagnostics; + } +} diff --git a/src/doctor/src/DoctorServiceProvider.php b/src/doctor/src/DoctorServiceProvider.php new file mode 100644 index 0000000000..8ed6deac53 --- /dev/null +++ b/src/doctor/src/DoctorServiceProvider.php @@ -0,0 +1,98 @@ +mergeConfigFrom(__DIR__ . '/../config/doctor.php', 'doctor'); + + $this->app->singleton(Doctor::class, function (Application $app): Doctor { + return (new Doctor($app))->diagnostics([ + EnvironmentFileExists::class, + ApplicationKeyIsSet::class, + PhpVersionSatisfiesComposerRequirement::class, + RequiredPhpExtensionsAreLoaded::class, + RecommendedPhpExtensionsAreLoaded::class, + // ApplicationTimezoneIsValid is omitted: Hypervel rejects invalid timezones during boot. + ComposerAutoloadIsValid::class, + ComposerLockIsFresh::class, + ConfigurationFilesCanBeLoaded::class, + ConfigurationCanBeCached::class, + RequiredConfigurationValuesAreSet::class, + BootstrapCacheMatchesEnvironment::class, + DatabaseConnectionIsReachable::class, + SqliteDatabaseExists::class, + MigrationsAreUpToDate::class, + CacheStoreIsReachable::class, + RedisConnectionsAreReachable::class, + RedisSupportsConfiguredFeatures::class, + QueueConnectionIsReachable::class, + QueueConnectionIsAsynchronous::class, + SessionDriverIsReachable::class, + ScheduledTasksRequireScheduler::class, + PublicStorageLinkExists::class, + FilesystemDisksAreReachable::class, + StorageIsWritable::class, + DebugModeMatchesEnvironment::class, + EnvironmentFileIsGitIgnored::class, + ComposerAuditPasses::class, + ]); + }); + } + + /** + * Bootstrap the package services. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + DiagnosticMakeCommand::class, + DoctorCommand::class, + ]); + + $this->publishes([ + __DIR__ . '/../config/doctor.php' => config_path('doctor.php'), + ], 'doctor-config'); + } + } +} diff --git a/src/doctor/src/EnvironmentMode.php b/src/doctor/src/EnvironmentMode.php new file mode 100644 index 0000000000..a39936044b --- /dev/null +++ b/src/doctor/src/EnvironmentMode.php @@ -0,0 +1,79 @@ +environment()); + } + + /** + * Resolve a Doctor environment mode from a Hypervel environment name. + */ + public static function fromHypervelEnvironment(string $environment): self + { + return self::configuredEnvironments()[$environment] ?? self::Production; + } + + /** + * Get the Doctor mode for each configured Hypervel environment name. + * + * @return array + */ + private static function configuredEnvironments(): array + { + $configured = []; + + foreach (config()->array('doctor.environments') as $mode => $environments) { + $resolved = is_string($mode) ? self::tryFrom($mode) : null; + + if ($resolved === null) { + throw new InvalidArgumentException(sprintf( + 'Invalid Doctor environment mode [%s] configured.', + is_string($mode) ? $mode : get_debug_type($mode), + )); + } + + if (! is_array($environments)) { + throw new InvalidArgumentException(sprintf( + 'Hypervel environment names for Doctor mode [%s] must be an array, [%s] given.', + $mode, + get_debug_type($environments), + )); + } + + foreach ($environments as $environment) { + if (isset($configured[$environment]) && $configured[$environment] !== $resolved) { + throw new InvalidArgumentException(sprintf( + 'Hypervel environment [%s] is configured for multiple Doctor modes.', + $environment, + )); + } + + $configured[$environment] = $resolved; + } + } + + return $configured; + } + + /** + * Determine whether this mode should use production expectations. + */ + public function isProduction(): bool + { + return $this === self::Production; + } +} diff --git a/src/doctor/src/Facades/Doctor.php b/src/doctor/src/Facades/Doctor.php new file mode 100644 index 0000000000..e380bdbe63 --- /dev/null +++ b/src/doctor/src/Facades/Doctor.php @@ -0,0 +1,38 @@ + $diagnostics) + * @method static \Hypervel\Doctor\PendingRun except(string ...$selectors) + * @method static \Hypervel\Doctor\Results\DiagnosticFixOutcome fix(\Hypervel\Doctor\Results\DiagnosticOutcome $outcome, string|null $option = null) + * @method static \Hypervel\Doctor\PendingRun fixUsing(Closure $callback) + * @method static bool hasDiagnostics() + * @method static \Hypervel\Doctor\PendingRun observeUsing(Closure $callback) + * @method static \Hypervel\Doctor\PendingRun only(string ...$selectors) + * @method static array registered() + * @method static \Hypervel\Doctor\Results\DiagnosticReport run() + * @method static array> selectedByGroup(\Hypervel\Doctor\DiagnosticSelection|null $selection = null) + * + * @see \Hypervel\Doctor\Doctor + */ +class Doctor extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return DoctorManager::class; + } +} diff --git a/src/doctor/src/PendingRun.php b/src/doctor/src/PendingRun.php new file mode 100644 index 0000000000..64dc4c18b8 --- /dev/null +++ b/src/doctor/src/PendingRun.php @@ -0,0 +1,272 @@ +selection = $this->selection->constrain(only: $selectors); + + return $this; + } + + /** + * Exclude diagnostics matching the given selectors from the run. + */ + public function except(string ...$selectors): self + { + $this->selection = $this->selection->constrain(except: $selectors); + + return $this; + } + + /** + * Stop the run after the first failing diagnostic. + */ + public function bail(bool $bail = true): self + { + $this->bail = $bail; + + return $this; + } + + /** + * Observe each diagnostic group through the given callback. + * + * The callback receives the group name and a controlled continuation that + * runs the group. The continuation accepts an optional "before" callback + * invoked before each diagnostic and an optional "after" callback invoked + * with each outcome. + * + * @param Closure(string, GroupContinuation): void $callback + */ + public function observeUsing(Closure $callback): self + { + $this->observer = $callback; + + return $this; + } + + /** + * Apply fixes approved by the given callback. + * + * The callback may return false to skip the fix, true to apply a plain + * fix, or one of the result's fix option values to apply the fix with + * that choice. + * + * @param Closure(DiagnosticOutcome): (bool|string) $callback + */ + public function fixUsing(Closure $callback): self + { + $this->fix = $callback; + + return $this; + } + + /** + * Invoke the given callback when applied fixes require the diagnostics to be re-run. + */ + public function beforeRerun(Closure $callback): self + { + $this->beforeRerun = $callback; + + return $this; + } + + /** + * Run the diagnostics. + */ + public function run(): DiagnosticReport + { + $report = $this->check(); + + if ($this->fix === null) { + return $report; + } + + $fixes = $this->fixes($report, $this->fix); + + if ($fixes === []) { + return $report; + } + + if ($this->beforeRerun !== null) { + ($this->beforeRerun)(); + } + + return $this->check()->withFixes($fixes); + } + + /** + * Check the selected diagnostics and gather their outcomes into a report. + */ + protected function check(): DiagnosticReport + { + $outcomes = []; + + foreach ($this->doctor->selectedByGroup($this->selection) as $group => $diagnostics) { + $groupOutcomes = $this->checkGroup($group, $diagnostics); + $outcomes = [...$outcomes, ...$groupOutcomes]; + + if ($this->bail && $this->hasFailure($groupOutcomes)) { + break; + } + } + + return new DiagnosticReport($outcomes); + } + + /** + * Check a diagnostic group while allowing its progress to be observed. + * + * @param list $diagnostics + * @return list + */ + protected function checkGroup(string $group, array $diagnostics): array + { + if ($this->observer === null) { + return $this->checkDiagnostics($diagnostics); + } + + $outcomes = null; + + ($this->observer)($group, function (?Closure $before = null, ?Closure $after = null) use (&$outcomes, $diagnostics): void { + $outcomes = $this->checkDiagnostics($diagnostics, $before, $after); + }); + + return $outcomes ?? throw new LogicException('A diagnostic group observer must invoke its continuation.'); + } + + /** + * Check the given diagnostics. + * + * @param list $diagnostics + * @param null|(Closure(Diagnostic): void) $before + * @param null|(Closure(DiagnosticOutcome): void) $after + * @return list + */ + protected function checkDiagnostics(array $diagnostics, ?Closure $before = null, ?Closure $after = null): array + { + $outcomes = []; + + foreach ($diagnostics as $diagnostic) { + if ($before !== null) { + $before($diagnostic); + } + + $outcome = $this->doctor->check($diagnostic); + $outcomes[] = $outcome; + + if ($after !== null) { + $after($outcome); + } + + if ($this->bail && $outcome->result->status->failed()) { + break; + } + } + + return $outcomes; + } + + /** + * Determine whether the outcomes contain a failure. + * + * @param list $outcomes + */ + protected function hasFailure(array $outcomes): bool + { + foreach ($outcomes as $outcome) { + if ($outcome->result->status->failed()) { + return true; + } + } + + return false; + } + + /** + * Apply the approved fixes for the report's failing diagnostics. + * + * @param Closure(DiagnosticOutcome): (bool|string) $fix + * @return list + */ + protected function fixes(DiagnosticReport $report, Closure $fix): array + { + $fixes = []; + + foreach ($report->diagnostics() as $outcome) { + if (! $outcome->fixable()) { + continue; + } + + $decision = $fix($outcome); + + if ($decision === false) { + continue; + } + + $fixes[] = $this->doctor->fix($outcome, is_string($decision) ? $decision : null); + } + + return $fixes; + } +} diff --git a/src/doctor/src/Renderers/AgentRenderer.php b/src/doctor/src/Renderers/AgentRenderer.php new file mode 100644 index 0000000000..1bf6a74a4c --- /dev/null +++ b/src/doctor/src/Renderers/AgentRenderer.php @@ -0,0 +1,95 @@ +payload($report), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + /** + * Build the agent-optimized report payload. + * + * Follows the agent-optimized output convention established by Laravel + * PAO: aggregate counts up front and only actionable outcomes itemized, + * so agents get a minimal, parseable report. + * + * @return array + */ + protected function payload(DiagnosticReport $report): array + { + $counts = array_fill_keys(array_column(Status::cases(), 'value'), 0); + $issues = []; + + foreach ($report->diagnostics() as $outcome) { + ++$counts[$outcome->result->status->value]; + + if ($outcome->result->status !== Status::Pass && $outcome->result->status !== Status::Skip) { + $issues[] = $this->issue($outcome); + } + } + + return array_filter([ + 'tool' => 'doctor', + 'result' => $report->hasFailures() ? 'failed' : 'passed', + 'diagnostics' => array_sum($counts), + 'failed' => $counts[Status::Fail->value] + $counts[Status::Error->value], + 'warnings' => $counts[Status::Warn->value], + 'notices' => $counts[Status::Notice->value], + 'passed' => $counts[Status::Pass->value], + 'skipped' => $counts[Status::Skip->value], + 'issues' => $issues, + 'fixes' => array_map($this->fix(...), $report->fixes()), + ], static fn (mixed $value): bool => $value !== []); + } + + /** + * Build the payload entry for an actionable diagnostic outcome. + * + * The fixable flag is an instruction to re-run with --fix, so fixes that + * require a choice omit it and expose their options instead, letting the + * agent apply the change itself or escalate a concrete shortlist. + * + * @return array + */ + protected function issue(DiagnosticOutcome $outcome): array + { + return array_filter([ + 'name' => $outcome->diagnostic->name, + 'status' => $outcome->result->status->value, + 'summary' => $outcome->result->summary, + 'details' => $outcome->result->details, + 'fix' => $outcome->result->remediation, + 'fixable' => ($outcome->fixable() && ! $outcome->fixRequiresOption()) ?: null, + 'options' => $outcome->result->fixOptions, + 'links' => array_column($outcome->result->links, 'agentUrl', 'label'), + ], static fn (mixed $value): bool => $value !== null && $value !== []); + } + + /** + * Build the payload entry for an applied fix. + * + * @return array + */ + protected function fix(DiagnosticFixOutcome $outcome): array + { + return array_filter([ + 'name' => $outcome->diagnostic->name, + 'status' => $outcome->result->status->value, + 'summary' => $outcome->result->summary, + 'details' => $outcome->result->details, + ], static fn (mixed $value): bool => $value !== null); + } +} diff --git a/src/doctor/src/Renderers/CliRenderer.php b/src/doctor/src/Renderers/CliRenderer.php new file mode 100644 index 0000000000..77dc7ca59e --- /dev/null +++ b/src/doctor/src/Renderers/CliRenderer.php @@ -0,0 +1,411 @@ +output->isVerbose(); + $notices = []; + $failedFixes = $this->failedFixesByDiagnostic($report->fixes()); + $mergedFixes = []; + + foreach ($report->diagnostics() as $outcome) { + if ($outcome->result->status === Status::Notice) { + $notices[] = $outcome; + + continue; + } + + if ($this->isReportableIssue($outcome)) { + $failedFix = $failedFixes[$outcome->diagnostic::class] ?? null; + + if ($failedFix !== null) { + $mergedFixes[] = $failedFix; + } + + $this->renderIssue($outcome, $failedFix); + + continue; + } + + if (! $verbose && in_array($outcome->result->status, [Status::Pass, Status::Skip], true)) { + continue; + } + + $this->output->writeln(sprintf( + '[%s] %s (%s): %s', + $outcome->result->status->value, + $outcome->diagnostic->name, + $outcome->source()->label(), + $outcome->result->summary, + )); + + $this->renderDiagnosticDetails($outcome); + } + + $this->renderNotices($notices); + $this->renderFixes(array_values(array_filter( + $report->fixes(), + static fn (DiagnosticFixOutcome $fix): bool => ! in_array($fix, $mergedFixes, true), + ))); + + if ($report->hasFailures()) { + error('Doctor found failing diagnostics.'); + + return; + } + + if ($report->hasWarnings()) { + warning('Doctor found warnings.'); + + return; + } + + if ($report->fixes() !== []) { + info('All diagnostics passed or were fixed.'); + + return; + } + + info('All diagnostics passed.'); + } + + /** + * Render a diagnostic fix confirmation as a callout. + */ + public function renderFixConfirmation(DiagnosticOutcome $outcome): void + { + callout( + label: $outcome->diagnostic->name, + content: $this->fixConfirmationCalloutContent($outcome), + type: $outcome->result->status === Status::Warn ? 'warning' : 'error', + info: $outcome->source()->label(), + ); + } + + /** + * Determine whether the diagnostic should be displayed as an issue. + */ + protected function isReportableIssue(DiagnosticOutcome $outcome): bool + { + return in_array($outcome->result->status, [Status::Warn, Status::Fail, Status::Error], true); + } + + /** + * Render an issue diagnostic as a callout. + */ + protected function renderIssue(DiagnosticOutcome $outcome, ?DiagnosticFixOutcome $failedFix = null): void + { + callout( + label: $outcome->diagnostic->name, + content: $this->diagnosticCalloutContent($outcome, failedFix: $failedFix), + type: $this->issueCalloutType($outcome, $failedFix), + info: $outcome->source()->label(), + ); + } + + /** + * Get the callout type for an issue, escalating when a fix failed. + */ + protected function issueCalloutType(DiagnosticOutcome $outcome, ?DiagnosticFixOutcome $failedFix): string + { + if ($failedFix !== null) { + return 'error'; + } + + return $outcome->result->status === Status::Warn ? 'warning' : 'error'; + } + + /** + * Key the failed fixes by the diagnostic that attempted them. + * + * @param list $fixes + * @return array + */ + protected function failedFixesByDiagnostic(array $fixes): array + { + $failed = []; + + foreach ($fixes as $fix) { + if ($fix->result->status->failed()) { + $failed[$fix->diagnostic::class] = $fix; + } + } + + return $failed; + } + + /** + * Render diagnostic details, remediation, and links. + */ + protected function renderDiagnosticDetails(DiagnosticOutcome $outcome): void + { + if ($outcome->result->details !== null) { + $this->output->writeln(' ' . $outcome->result->details); + } + + if ($outcome->result->remediation !== null) { + $this->output->writeln(' ' . $outcome->result->remediation); + } + + foreach ($outcome->result->links as $link) { + $this->output->writeln(' ' . $link->url); + } + } + + /** + * Render notice diagnostics as callouts grouped by source. + * + * @param list $notices + */ + protected function renderNotices(array $notices): void + { + if ($notices === []) { + return; + } + + foreach ($this->noticesBySource($notices) as $source => $sourceNotices) { + callout( + label: Str::plural('Notice', count($sourceNotices)), + content: $this->noticesCalloutContent($sourceNotices), + info: $source, + ); + } + } + + /** + * Group notices by their source. + * + * @param list $notices + * @return array> + */ + protected function noticesBySource(array $notices): array + { + return collect($notices) + ->groupBy(static fn (DiagnosticOutcome $notice): string => $notice->source()->label()) + ->map(static fn (Collection $notices): array => array_values($notices->all())) + ->all(); + } + + /** + * Format notice content for a callout. + * + * @param list $notices + * @return list + */ + protected function noticesCalloutContent(array $notices): array + { + $content = []; + + foreach ($notices as $notice) { + $content[] = $this->noticeItem($notice); + + foreach ($notice->result->links as $link) { + $content[] = $this->promptLink($link); + } + } + + return $content; + } + + /** + * Format a notice as a compact list item. + */ + protected function noticeItem(DiagnosticOutcome $outcome): string + { + $parts = [$outcome->result->summary]; + + if ($outcome->result->details !== null) { + $parts[] = $outcome->result->details; + } + + if ($outcome->result->remediation !== null) { + $parts[] = $outcome->result->remediation; + } + + return Str::squish(implode(' ', $parts)); + } + + /** + * Render diagnostic fixes as callouts grouped by source. + * + * @param list $fixes + */ + protected function renderFixes(array $fixes): void + { + if ($fixes === []) { + return; + } + + foreach ($this->fixesBySource($fixes) as $source => $sourceFixes) { + callout( + label: count($sourceFixes) === 1 ? $sourceFixes[0]->diagnostic->name : 'Fixes', + content: $this->fixesCalloutContent($sourceFixes), + type: $this->fixesCalloutType($sourceFixes), + info: $source, + ); + } + } + + /** + * Group fixes by their source. + * + * @param list $fixes + * @return array> + */ + protected function fixesBySource(array $fixes): array + { + return collect($fixes) + ->groupBy(static fn (DiagnosticFixOutcome $fix): string => $fix->source()->label()) + ->map(static fn (Collection $fixes): array => array_values($fixes->all())) + ->all(); + } + + /** + * Format fix content for a callout. + * + * @param list $fixes + * @return list + */ + protected function fixesCalloutContent(array $fixes): array + { + if (count($fixes) === 1) { + return [$this->fixSummary($fixes[0])]; + } + + return [Element::bulletedList(array_map( + fn (DiagnosticFixOutcome $fix): string => $this->fixItem($fix), + $fixes, + ), spaced: true)]; + } + + /** + * Format a fix outcome as a compact list item. + */ + protected function fixItem(DiagnosticFixOutcome $outcome): string + { + return sprintf('%s: %s', $outcome->diagnostic->name, $this->fixSummary($outcome)); + } + + /** + * Format a fix outcome's summary and details as a single line. + */ + protected function fixSummary(DiagnosticFixOutcome $outcome): string + { + $parts = [$outcome->result->summary]; + + if ($outcome->result->details !== null) { + $parts[] = $outcome->result->details; + } + + return Str::squish(implode(' ', $parts)); + } + + /** + * Get the callout type for the fix outcomes. + * + * @param list $fixes + */ + protected function fixesCalloutType(array $fixes): ?string + { + foreach ($fixes as $fix) { + if ($fix->result->status->failed()) { + return 'error'; + } + } + + foreach ($fixes as $fix) { + if ($fix->result->status === Status::Warn) { + return 'warning'; + } + } + + return null; + } + + /** + * Format diagnostic content for a callout. + * + * @return list + */ + protected function diagnosticCalloutContent(DiagnosticOutcome $outcome, bool $includeRemediation = true, ?DiagnosticFixOutcome $failedFix = null): array + { + $content = [$outcome->result->summary]; + + if ($outcome->result->details !== null) { + $content[] = Element::heading('Details'); + $content[] = $outcome->result->details; + } + + if ($failedFix !== null) { + $content[] = Element::heading('Fix failed'); + $content[] = $this->fixSummary($failedFix); + } + + if ($includeRemediation && $failedFix === null && $outcome->result->remediation !== null) { + $content[] = Element::heading('Suggested fix'); + $content[] = $outcome->result->remediation; + } + + foreach ($outcome->result->links as $link) { + $content[] = $this->promptLink($link); + } + + return $content; + } + + /** + * Format fix confirmation content for a callout. + * + * @return list + */ + protected function fixConfirmationCalloutContent(DiagnosticOutcome $outcome): array + { + return $this->diagnosticCalloutContent($outcome, includeRemediation: false); + } + + /** + * Format a related link as a native Prompts element with a shortened URL. + */ + protected function promptLink(Link $link): PromptLink + { + return Element::link( + url: $link->url, + label: Str::before(Str::after($link->url, '://'), '#'), + ); + } +} diff --git a/src/doctor/src/Renderers/GithubRenderer.php b/src/doctor/src/Renderers/GithubRenderer.php new file mode 100644 index 0000000000..05be873897 --- /dev/null +++ b/src/doctor/src/Renderers/GithubRenderer.php @@ -0,0 +1,103 @@ + + */ + public function annotations(DiagnosticReport $report): array + { + $annotations = []; + + foreach ($report->diagnostics() as $outcome) { + if ($outcome->result->status === Status::Pass || $outcome->result->status === Status::Skip) { + continue; + } + + $annotations[] = sprintf( + '::%s title=%s::%s', + $this->level($outcome), + $this->escapeProperty($this->title($outcome)), + $this->escapeData($this->summary($outcome)), + ); + } + + return $annotations; + } + + /** + * Get the annotation level for a diagnostic outcome. + */ + protected function level(DiagnosticOutcome $outcome): string + { + return match ($outcome->result->status) { + Status::Notice => 'notice', + Status::Warn => 'warning', + default => 'error', + }; + } + + /** + * Build an annotation title. + */ + protected function title(DiagnosticOutcome $outcome): string + { + return sprintf('%s (%s)', $outcome->diagnostic->name, $outcome->source()->label()); + } + + /** + * Build an annotation summary. + */ + protected function summary(DiagnosticOutcome $outcome): string + { + $parts = [$outcome->result->summary]; + + if ($outcome->result->details !== null) { + $parts[] = $outcome->result->details; + } + + if ($outcome->result->remediation !== null) { + $parts[] = $outcome->result->remediation; + } + + foreach ($outcome->result->links as $link) { + $parts[] = $link->url; + } + + return implode(' ', $parts); + } + + /** + * Escape a GitHub workflow command property value. + */ + protected function escapeProperty(string $value): string + { + return str_replace( + ['%', "\r", "\n", ':', ','], + ['%25', '%0D', '%0A', '%3A', '%2C'], + $value, + ); + } + + /** + * Escape a GitHub workflow command message body. + */ + protected function escapeData(string $value): string + { + return str_replace( + ['%', "\r", "\n"], + ['%25', '%0D', '%0A'], + $value, + ); + } +} diff --git a/src/doctor/src/Results/DiagnosticFixOutcome.php b/src/doctor/src/Results/DiagnosticFixOutcome.php new file mode 100644 index 0000000000..ba3abd6290 --- /dev/null +++ b/src/doctor/src/Results/DiagnosticFixOutcome.php @@ -0,0 +1,69 @@ +> + */ +class DiagnosticFixOutcome implements Arrayable, JsonSerializable +{ + /** + * Create a new diagnostic fix outcome instance. + */ + public function __construct( + public Diagnostic $diagnostic, + public FixResult $result, + ) { + } + + /** + * Get the diagnostic's source metadata. + */ + public function source(): DiagnosticSource + { + return DiagnosticSource::for($this->diagnostic); + } + + /** + * Get the array representation of the fix outcome. + * + * @return array + */ + public function toArray(): array + { + $source = $this->source(); + + return [ + 'class' => $this->diagnostic::class, + 'group' => $this->diagnostic->group, + 'name' => $this->diagnostic->name, + 'source' => [ + 'label' => $source->label(), + 'package' => $source->package, + 'file' => $source->relativeFile, + 'application' => $source->application, + ], + 'code' => $this->result->code, + 'status' => $this->result->status->value, + 'summary' => $this->result->summary, + 'details' => $this->result->details, + ]; + } + + /** + * Get the JSON representation of the fix outcome. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/doctor/src/Results/DiagnosticOutcome.php b/src/doctor/src/Results/DiagnosticOutcome.php new file mode 100644 index 0000000000..4f565d7813 --- /dev/null +++ b/src/doctor/src/Results/DiagnosticOutcome.php @@ -0,0 +1,107 @@ +> + */ +class DiagnosticOutcome implements Arrayable, JsonSerializable +{ + /** + * Create a new diagnostic outcome instance. + */ + public function __construct( + public Diagnostic $diagnostic, + public DiagnosticResult $result, + ) { + } + + /** + * Get the diagnostic's source metadata. + */ + public function source(): DiagnosticSource + { + return DiagnosticSource::for($this->diagnostic); + } + + /** + * Determine whether this outcome may be fixed. + */ + public function fixable(): bool + { + return $this->diagnostic instanceof Fixable + && $this->result->status === Status::Fail + && $this->result->fixableIn(EnvironmentMode::current()); + } + + /** + * Determine whether the fix requires choosing one of the result's options. + */ + public function fixRequiresOption(): bool + { + return $this->fixable() && $this->result->fixOptions !== []; + } + + /** + * Get the array representation of the outcome. + * + * @return array + */ + public function toArray(): array + { + $source = $this->source(); + + return [ + 'class' => $this->diagnostic::class, + 'group' => $this->diagnostic->group, + 'name' => $this->diagnostic->name, + 'source' => [ + 'label' => $source->label(), + 'package' => $source->package, + 'file' => $source->relativeFile, + 'application' => $source->application, + ], + 'code' => $this->result->code, + 'status' => $this->result->status->value, + 'fixable' => $this->fixable(), + 'options' => $this->result->fixOptions, + 'summary' => $this->result->summary, + 'details' => $this->result->details, + 'remediation' => $this->result->remediation, + 'links' => $this->links(), + ]; + } + + /** + * Get the JSON representation of the outcome. + * + * @return array + */ + public function jsonSerialize(): array + { + return [...$this->toArray(), 'links' => (object) $this->links(agent: true)]; + } + + /** + * Get the result's links keyed by label. + * + * @return array + */ + protected function links(bool $agent = false): array + { + return array_column( + $this->result->links, + $agent ? 'agentUrl' : 'url', + 'label', + ); + } +} diff --git a/src/doctor/src/Results/DiagnosticReport.php b/src/doctor/src/Results/DiagnosticReport.php new file mode 100644 index 0000000000..ab3b270b31 --- /dev/null +++ b/src/doctor/src/Results/DiagnosticReport.php @@ -0,0 +1,135 @@ +> + */ +class DiagnosticReport implements Arrayable, JsonSerializable +{ + /** + * The version of the report's machine-readable payload. + */ + public const int VERSION = 1; + + /** + * Create a new diagnostic report instance. + * + * @param list $diagnostics + * @param list $fixes + */ + public function __construct( + protected array $diagnostics = [], + protected array $fixes = [], + ) { + } + + /** + * Get the diagnostic outcomes. + * + * @return list + */ + public function diagnostics(): array + { + return $this->diagnostics; + } + + /** + * Get the diagnostic fix outcomes. + * + * @return list + */ + public function fixes(): array + { + return $this->fixes; + } + + /** + * Add fix outcomes to the report. + * + * @param list $fixes + */ + public function withFixes(array $fixes): self + { + return new self($this->diagnostics, [...$this->fixes, ...$fixes]); + } + + /** + * Determine whether the report has failures. + */ + public function hasFailures(): bool + { + foreach ($this->diagnostics as $outcome) { + if ($outcome->result->status->failed()) { + return true; + } + } + + foreach ($this->fixes as $outcome) { + if ($outcome->result->status->failed()) { + return true; + } + } + + return false; + } + + /** + * Determine whether the report has warnings. + */ + public function hasWarnings(): bool + { + foreach ($this->diagnostics as $outcome) { + if ($outcome->result->status === Status::Warn) { + return true; + } + } + + foreach ($this->fixes as $outcome) { + if ($outcome->result->status === Status::Warn) { + return true; + } + } + + return false; + } + + /** + * Get the array representation of the report. + * + * @return array + */ + public function toArray(): array + { + return [ + 'version' => self::VERSION, + 'diagnostics' => array_map( + static fn (DiagnosticOutcome $outcome): array => $outcome->toArray(), + $this->diagnostics, + ), + 'fixes' => array_map( + static fn (DiagnosticFixOutcome $outcome): array => $outcome->toArray(), + $this->fixes, + ), + ]; + } + + /** + * Get the JSON representation of the report. + * + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'version' => self::VERSION, + 'diagnostics' => $this->diagnostics, + 'fixes' => $this->fixes, + ]; + } +} diff --git a/src/doctor/src/Results/DiagnosticResult.php b/src/doctor/src/Results/DiagnosticResult.php new file mode 100644 index 0000000000..daa707c519 --- /dev/null +++ b/src/doctor/src/Results/DiagnosticResult.php @@ -0,0 +1,213 @@ + + */ + public array $fixableEnvironments = []; + + public ?string $details = null; + + /** + * The remediation message for the result. + */ + public ?string $remediation = null; + + /** + * The related links for the result. + * + * @var list + */ + public array $links = []; + + /** + * The structured context for the result. + * + * @var array + */ + public array $context = []; + + public ?string $confirmation = null; + + /** + * The choices for the diagnostic's fix, keyed by option value. + * + * An empty list means the fix needs no choice. + * + * @var array + */ + public array $fixOptions = []; + + /** + * The label for the select entry that declines a fix offering options. + * + * Null renders the generic skip entry. + */ + public ?string $fixDeclineLabel = null; + + /** + * Create a new diagnostic result instance. + */ + public function __construct( + public Status $status, + public string $summary, + public ?string $code = null, + ) { + } + + /** + * Create a passing diagnostic result. + */ + public static function pass(string $summary = 'Passed', ?string $code = null): static + { + return new static(Status::Pass, $summary, $code); + } + + /** + * Create a warning diagnostic result. + */ + public static function warn(string $summary, ?string $code = null): static + { + return new static(Status::Warn, $summary, $code); + } + + /** + * Create a notice diagnostic result. + */ + public static function notice(string $summary, ?string $code = null): static + { + return new static(Status::Notice, $summary, $code); + } + + /** + * Create a failing diagnostic result. + */ + public static function fail(string $summary, ?string $code = null): static + { + return new static(Status::Fail, $summary, $code); + } + + /** + * Create a skipped diagnostic result. + */ + public static function skip(string $summary, ?string $code = null): static + { + return new static(Status::Skip, $summary, $code); + } + + /** + * Create an error diagnostic result. + */ + public static function error(string $summary, ?string $code = null): static + { + return new static(Status::Error, $summary, $code); + } + + /** + * Add evidence to the diagnostic result. + * + * Prefer message tokens for short identifying values like versions, paths, + * constraints, or counts. Use details for unbounded or multi-line evidence + * such as exception messages, process output, and bullet lists. + */ + public function withDetails(string $details): static + { + $this->details = $details; + + return $this; + } + + /** + * Add remediation text to the diagnostic result. + */ + public function suggest(string $message): static + { + $this->remediation = $message; + + return $this; + } + + /** + * Set the confirmation prompt for the diagnostic fix. + */ + public function confirmUsing(string $prompt): static + { + $this->confirmation = $prompt; + + return $this; + } + + /** + * Mark this result as eligible for the diagnostic's fix. + */ + public function fixable(EnvironmentMode ...$environments): static + { + $this->fixable = true; + $this->fixableEnvironments = array_values($environments); + + return $this; + } + + /** + * Offer a choice of repairs for the diagnostic's fix. + * + * @param array $options labels keyed by option value + * @param null|string $decline label for the entry that declines the fix + */ + public function fixOptions(array $options, ?string $decline = null): static + { + $this->fixOptions = $options; + $this->fixDeclineLabel = $decline; + + return $this; + } + + /** + * Determine whether the fix may be applied in the given environment mode. + */ + public function fixableIn(EnvironmentMode $environment): bool + { + return $this->fixable + && ($this->fixableEnvironments === [] + || in_array($environment, $this->fixableEnvironments, true)); + } + + /** + * Add a related link to the diagnostic result. + */ + public function link(Link $link): static + { + $this->links[] = $link; + + return $this; + } + + /** + * Add context to the diagnostic result. + */ + public function withContext(string $key, mixed $value): static + { + $this->context[$key] = $value; + + return $this; + } +} diff --git a/src/doctor/src/Results/FixResult.php b/src/doctor/src/Results/FixResult.php new file mode 100644 index 0000000000..903656e93f --- /dev/null +++ b/src/doctor/src/Results/FixResult.php @@ -0,0 +1,102 @@ + + */ + public array $context = []; + + /** + * Create a new fix result instance. + */ + public function __construct( + public Status $status, + public string $summary, + public ?string $code = null, + ) { + } + + /** + * Create a passing fix result. + */ + public static function pass(string $summary = 'Fixed', ?string $code = null): static + { + return new static(Status::Pass, $summary, $code); + } + + /** + * Create a warning fix result. + */ + public static function warn(string $summary, ?string $code = null): static + { + return new static(Status::Warn, $summary, $code); + } + + /** + * Create a notice fix result. + */ + public static function notice(string $summary, ?string $code = null): static + { + return new static(Status::Notice, $summary, $code); + } + + /** + * Create a failing fix result. + */ + public static function fail(string $summary, ?string $code = null): static + { + return new static(Status::Fail, $summary, $code); + } + + /** + * Create a skipped fix result. + */ + public static function skip(string $summary, ?string $code = null): static + { + return new static(Status::Skip, $summary, $code); + } + + /** + * Create an error fix result. + */ + public static function error(string $summary, ?string $code = null): static + { + return new static(Status::Error, $summary, $code); + } + + /** + * Add evidence to the fix result. + * + * Prefer message tokens for short identifying values like versions, paths, + * constraints, or counts. Use details for unbounded or multi-line evidence + * such as exception messages, process output, and bullet lists. + */ + public function withDetails(string $details): static + { + $this->details = $details; + + return $this; + } + + /** + * Add context to the fix result. + */ + public function withContext(string $key, mixed $value): static + { + $this->context[$key] = $value; + + return $this; + } +} diff --git a/src/doctor/src/Results/Link.php b/src/doctor/src/Results/Link.php new file mode 100644 index 0000000000..bd6bb56916 --- /dev/null +++ b/src/doctor/src/Results/Link.php @@ -0,0 +1,53 @@ + $tokens + */ + public function replace(array $tokens): self + { + return new self( + Str::swap($tokens, $this->label), + Str::swap($tokens, $this->url), + Str::swap($tokens, $this->agentUrl), + ); + } +} diff --git a/src/doctor/src/Results/Message.php b/src/doctor/src/Results/Message.php new file mode 100644 index 0000000000..174d9dcfc4 --- /dev/null +++ b/src/doctor/src/Results/Message.php @@ -0,0 +1,46 @@ + + */ + public array $links = []; + + /** + * Create a new diagnostic message definition. + */ + public function __construct( + public readonly string $summary, + public readonly ?string $remediation = null, + public readonly ?string $confirmation = null, + ) { + } + + /** + * Create a new diagnostic message definition. + */ + public static function make( + string $summary, + ?string $remediation = null, + ?string $confirmation = null, + ): self { + return new self($summary, $remediation, $confirmation); + } + + /** + * Add a related link to the message. + */ + public function link(Link $link): self + { + $this->links[] = $link; + + return $this; + } +} diff --git a/src/doctor/src/Results/Status.php b/src/doctor/src/Results/Status.php new file mode 100644 index 0000000000..e41b6d7f4e --- /dev/null +++ b/src/doctor/src/Results/Status.php @@ -0,0 +1,23 @@ + $seen + * @return list + */ + public static function cacheStores(string $store, array $seen = []): array + { + if (($cycleStart = array_search($store, $seen, true)) !== false) { + throw new InvalidArgumentException('Circular cache store definition detected: ' . implode(' -> ', [...array_slice($seen, $cycleStart), $store]) . '.'); + } + + $driver = config("cache.stores.{$store}.driver"); + + if (! in_array($driver, ['stack', 'failover'], true)) { + return [$store]; + } + + $active = []; + + foreach (config()->array("cache.stores.{$store}.stores") as $name => $configuration) { + $nested = $driver === 'stack' && is_array($configuration) ? $name : $configuration; + + if ($driver === 'stack') { + $nested = $nested instanceof UnitEnum ? (string) enum_value($nested) : $nested; + $nested ??= config()->string('cache.default'); + } + + $active = [...$active, ...self::cacheStores($nested, [...$seen, $store])]; + } + + return array_values(array_unique($active)); + } + + /** + * Get the concrete channels used by a logging channel. + * + * @param list $seen + * @return list + */ + public static function logChannels(string $channel, array $seen = []): array + { + $channel = trim($channel); + + if (in_array($channel, $seen, true)) { + return []; + } + + if (config("logging.channels.{$channel}.driver") !== 'stack') { + return [$channel]; + } + + $channels = config("logging.channels.{$channel}.channels"); + + if (is_string($channels)) { + $channels = explode(',', $channels); + } + + if (! is_array($channels)) { + return []; + } + + $active = []; + + foreach ($channels as $nested) { + $nested = $nested instanceof UnitEnum ? (string) enum_value($nested) : $nested; + + if (is_string($nested) && $nested !== '') { + $active = [...$active, ...self::logChannels($nested, [...$seen, $channel])]; + } + } + + return array_values(array_unique($active)); + } + + /** + * Get the concrete mailers used by a mailer. + * + * @param list $seen + * @return list + */ + public static function mailers(string $mailer, array $seen = []): array + { + if (in_array($mailer, $seen, true)) { + return []; + } + + $configuration = self::mailerConfiguration($mailer); + $transport = $configuration['transport'] ?? $mailer; + + if (! in_array($transport, ['failover', 'roundrobin'], true)) { + return [$mailer]; + } + + $mailers = $configuration['mailers'] ?? null; + + if (! is_array($mailers)) { + return []; + } + + $active = []; + + foreach ($mailers as $nested) { + if (is_string($nested) && $nested !== '') { + $active = [...$active, ...self::mailers($nested, [...$seen, $mailer])]; + } + } + + return array_values(array_unique($active)); + } + + /** + * Get the mailer configuration after applying its connection URL. + * + * @return array + */ + public static function mailerConfiguration(string $mailer): array + { + $configuration = config("mail.mailers.{$mailer}"); + + if (! is_array($configuration)) { + return []; + } + + if (isset($configuration['url'])) { + $configuration = array_merge($configuration, (new ConfigurationUrlParser)->parseConfiguration($configuration)); + $configuration['transport'] = Arr::pull($configuration, 'driver'); + } + + return $configuration; + } + + /** + * Get the concrete connections used by a queue connection. + * + * @param list $seen + * @return list + */ + public static function queueConnections(string $connection, array $seen = []): array + { + if (($cycleStart = array_search($connection, $seen, true)) !== false) { + throw new InvalidArgumentException('Circular queue connection definition detected: ' . implode(' -> ', [...array_slice($seen, $cycleStart), $connection]) . '.'); + } + + if (config("queue.connections.{$connection}.driver") !== 'failover') { + return [$connection]; + } + + $active = []; + + foreach (config()->array("queue.connections.{$connection}.connections") as $nested) { + $nested = $nested instanceof UnitEnum ? (string) enum_value($nested) : $nested; + $nested = $nested === null || $nested === '' ? config()->string('queue.default') : $nested; + $active = [...$active, ...self::queueConnections($nested, [...$seen, $connection])]; + } + + return array_values(array_unique($active)); + } + + /** + * Get the Redis connections used by active cache, queue, and session drivers. + * + * @return list + */ + public static function redisConnections(): array + { + $connections = []; + $cache = Configured::string('cache.default'); + + foreach ($cache === null ? [] : self::cacheStores($cache) as $store) { + $configuration = config("cache.stores.{$store}"); + + if (! is_array($configuration) || ($configuration['driver'] ?? null) !== 'redis') { + continue; + } + + $connection = $configuration['connection'] ?? 'default'; + $connections[] = $connection; + $connections[] = $configuration['lock_connection'] ?? $connection; + } + + $queue = Configured::string('queue.default'); + + foreach ($queue === null ? [] : self::queueConnections($queue) as $name) { + $configuration = config("queue.connections.{$name}"); + + if (is_array($configuration) && ($configuration['driver'] ?? null) === 'redis') { + $connections[] = $configuration['connection'] ?? 'default'; + } + } + + if (config('session.driver') === 'redis') { + $connections[] = Configured::string('session.connection', SessionManager::DEFAULT_REDIS_CONNECTION); + } + + return array_values(array_unique($connections)); + } +} diff --git a/src/doctor/src/Support/Composer.php b/src/doctor/src/Support/Composer.php new file mode 100644 index 0000000000..83b090f585 --- /dev/null +++ b/src/doctor/src/Support/Composer.php @@ -0,0 +1,23 @@ + + */ + public static function command(): array + { + /** @var HypervelComposer $composer */ + $composer = app('composer'); + + return array_values($composer->findComposer()); + } +} diff --git a/src/doctor/src/Support/ComposerJson.php b/src/doctor/src/Support/ComposerJson.php new file mode 100644 index 0000000000..8f5b12e969 --- /dev/null +++ b/src/doctor/src/Support/ComposerJson.php @@ -0,0 +1,125 @@ + + */ + private ?array $contents = null; + + /** + * Determine whether the application has a composer.json file. + */ + public function exists(): bool + { + return File::isFile($this->path()); + } + + /** + * Get the application's PHP version constraint. + */ + public function phpConstraint(): ?string + { + $constraint = Arr::get($this->contents() ?? [], 'require.php'); + + return is_string($constraint) && $constraint !== '' ? $constraint : null; + } + + /** + * Get the Composer-required PHP extensions. + * + * @return list + */ + public function requiredExtensions(): array + { + return $this->extensionsFrom([ + ...$this->packageNames(Arr::get($this->contents() ?? [], 'require', [])), + ...$this->packageNames(Arr::get($this->contents() ?? [], 'require-dev', [])), + ]); + } + + /** + * Get the Composer-suggested PHP extensions. + * + * @return list + */ + public function suggestedExtensions(): array + { + return $this->extensionsFrom( + $this->packageNames(Arr::get($this->contents() ?? [], 'suggest', [])), + ); + } + + /** + * Get the decoded composer.json contents. + * + * @return null|array + */ + public function contents(): ?array + { + if ($this->contents !== null) { + return $this->contents; + } + + if (! $this->exists()) { + return null; + } + + $decoded = json_decode(File::get($this->path()), true); + + if (! is_array($decoded)) { + return null; + } + + /** @var array $decoded */ + return $this->contents = $decoded; + } + + /** + * Get package names from a Composer object. + * + * @return list + */ + private function packageNames(mixed $packages): array + { + if (! is_array($packages)) { + return []; + } + + return array_values(array_filter(array_keys($packages), is_string(...))); + } + + /** + * Get extension names from Composer package names. + * + * @param list $packages + * @return list + */ + private function extensionsFrom(array $packages): array + { + return array_values(array_unique(array_map( + static fn (string $package): string => substr($package, 4), + array_filter( + $packages, + static fn (string $package): bool => str_starts_with($package, 'ext-'), + ), + ))); + } + + /** + * Get the path to composer.json. + */ + private function path(): string + { + return base_path('composer.json'); + } +} diff --git a/src/doctor/src/Support/Configured.php b/src/doctor/src/Support/Configured.php new file mode 100644 index 0000000000..c6521d5b48 --- /dev/null +++ b/src/doctor/src/Support/Configured.php @@ -0,0 +1,40 @@ + $keys + * @return list + */ + public static function missing(array $keys): array + { + return array_values(array_filter( + $keys, + static fn (string $key): bool => blank(config($key)), + )); + } +} diff --git a/src/doctor/src/Support/Details.php b/src/doctor/src/Support/Details.php new file mode 100644 index 0000000000..d1af4ab07f --- /dev/null +++ b/src/doctor/src/Support/Details.php @@ -0,0 +1,42 @@ + $items + */ + public static function bullets(array $items): string + { + return collect($items) + ->map(static fn (string $item): string => str($item)->start('- ')->toString()) + ->implode(PHP_EOL); + } + + /** + * Format keyed failure messages. + * + * @param array $failures + */ + public static function failures(array $failures): string + { + return collect($failures) + ->map(static fn (string $message, string $name): string => sprintf('- %s: %s', $name, $message)) + ->implode(PHP_EOL); + } + + /** + * Get the most useful process output. + */ + public static function processOutput(string $output, string $errorOutput, string $fallback): string + { + $output = trim($errorOutput !== '' ? $errorOutput : $output); + + return $output === '' ? $fallback : $output; + } +} diff --git a/src/doctor/src/Support/EnvironmentConfiguration.php b/src/doctor/src/Support/EnvironmentConfiguration.php new file mode 100644 index 0000000000..c1bb3a735b --- /dev/null +++ b/src/doctor/src/Support/EnvironmentConfiguration.php @@ -0,0 +1,49 @@ +getCachedConfigPath(); + $arguments = [PHP_BINARY, 'artisan']; + + if (($input = new ArgvInput)->hasParameterOption('--env')) { + $arguments[] = '--env=' . $input->getParameterOption('--env'); + } + + $process = Process::path(base_path()) + ->env([ + ...array_fill_keys(DotenvManager::getLoadedVariableNames(), false), + 'APP_CONFIG_CACHE' => $cachePath, + ]); + + if (is_file($cachePath) + && ! (clone $process)->forever()->run([...$arguments, 'config:cache'])->successful()) { + return 'The environment file was updated, but the configuration cache could not be rebuilt. Run `php artisan config:cache` to see the error.'; + } + + $result = $process->run([...$arguments, 'config:show', $key, '--json']); + + if (! $result->successful()) { + return "The environment file was updated, but [{$key}] could not be read from a fresh application. Run `php artisan config:show {$key}` to inspect the configuration."; + } + + $value = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR); + config([$key => $value]); + + return $value === $expected + ? null + : "The environment file was updated, but [{$key}] still has a different value in a fresh application. Check the application configuration and external environment variables."; + } +} diff --git a/src/doctor/src/Support/InstalledPackages.php b/src/doctor/src/Support/InstalledPackages.php new file mode 100644 index 0000000000..3effb019ca --- /dev/null +++ b/src/doctor/src/Support/InstalledPackages.php @@ -0,0 +1,108 @@ +, providers: array}> + */ + public function platformRequirements(ComposerJson $composer): array + { + $vendorPath = $this->vendorPath(); + $path = $vendorPath . '/composer/installed.json'; + $installed = File::isFile($path) ? json_decode(File::get($path), true, flags: JSON_THROW_ON_ERROR) : []; + $root = $composer->contents() ?? []; + $packages = $installed['packages'] ?? []; + $requirements = []; + + $this->collect($requirements, $root['name'] ?? 'root', $root['require'] ?? [], 'requirements'); + + if (($installed['dev'] ?? false) === true) { + $this->collect($requirements, ($root['name'] ?? 'root') . ' (require-dev)', $root['require-dev'] ?? [], 'requirements'); + } + + $rootProviders = [...($root['provide'] ?? []), ...($root['replace'] ?? [])]; + $rootVersion = $root['version'] ?? null; + + if (in_array('self.version', $rootProviders, true) && File::isFile($vendorPath . '/composer/installed.php')) { + $metadata = require $vendorPath . '/composer/installed.php'; + $rootVersion = $metadata['root']['version'] ?? $rootVersion; + } + + $this->collect($requirements, $root['name'] ?? 'root', $rootProviders, 'providers', $rootVersion); + + foreach ($packages as $package) { + $this->collect($requirements, $package['name'], $package['require'] ?? [], 'requirements'); + $this->collect( + $requirements, + $package['name'], + [...($package['provide'] ?? []), ...($package['replace'] ?? [])], + 'providers', + $package['version_normalized'] ?? $package['version'] ?? null, + ); + } + + return $requirements; + } + + /** + * Get loaded extensions by their Composer platform names. + * + * @return array + */ + public function loadedExtensions(): array + { + $loaded = []; + + foreach (get_loaded_extensions() as $extension) { + if (! in_array($extension, ['Core', 'standard'], true)) { + $loaded['ext-' . str_replace(' ', '-', strtolower($extension))] = phpversion($extension) ?: '0'; + } + } + + return $loaded; + } + + /** + * Add platform links from one package to the snapshot. + * + * @param array, providers: array}> $requirements + * @param array $links + * @param 'providers'|'requirements' $type + */ + private function collect(array &$requirements, string $package, array $links, string $type, ?string $version = null): void + { + foreach ($links as $name => $constraint) { + if ($name !== 'php' && ! str_starts_with($name, 'ext-')) { + continue; + } + + if ($constraint === 'self.version') { + if ($version === null) { + continue; + } + + $constraint = $version; + } + + $requirements[$name] ??= ['requirements' => [], 'providers' => []]; + $requirements[$name][$type][$package] = $constraint; + } + } + + /** + * Get the vendor directory used by framework package discovery. + */ + protected function vendorPath(): string + { + return app(PackageManifest::class)->vendorPath; + } +} diff --git a/src/encryption/src/Commands/KeyGenerateCommand.php b/src/encryption/src/Commands/KeyGenerateCommand.php index 8dc18a66dc..05772a3c80 100644 --- a/src/encryption/src/Commands/KeyGenerateCommand.php +++ b/src/encryption/src/Commands/KeyGenerateCommand.php @@ -120,13 +120,13 @@ protected function writeNewEnvironmentFileWith(#[SensitiveParameter] string $key } /** - * Get a regex pattern that will match env APP_KEY with any random key. + * Get a regex pattern matching the current APP_KEY assignment, excluding ignored trailing whitespace or comments. */ protected function keyReplacementPattern(): string { $escaped = preg_quote($this->hypervel->make('config')->get('app.key') ?? '', '/'); - return "/^APP_KEY=([\"']?){$escaped}\\1(?=\\r?$)/m"; + return "/^(?:[ \\t]*export[ \\t]+)?\\KAPP_KEY=([\"']?){$escaped}\\1(?=[ \\t]*(?:#.*)?\\r?$)/m"; } /** diff --git a/src/foundation/README.md b/src/foundation/README.md index ada34a9f6a..6805c6d856 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -28,6 +28,8 @@ Laravel's `Application::handleRequest()` is omitted. Hypervel's HTTP server hand The `php artisan serve` command starts Hypervel's configured Swoole servers directly instead of PHP's built-in development server. +Environment detection honors startup `--env` arguments even when `runningInConsole()` is false, so server commands can select their environment while booting with HTTP semantics. + The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. Laravel's default Pail process is omitted because Hypervel has no Pail-equivalent command. Application logging remains controlled by the application's logging configuration. diff --git a/src/foundation/composer.json b/src/foundation/composer.json index cee6c580d4..f39d851f74 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -72,6 +72,7 @@ "hypervel/prompts": "^0.4", "hypervel/queue": "^0.4", "hypervel/rate-limiter": "^0.4", + "hypervel/redis": "^0.4", "hypervel/reflection": "^0.4", "hypervel/routing": "^0.4", "hypervel/server": "^0.4", diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 8b3f87b0ab..692d40c2c5 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -818,9 +818,8 @@ public function isProduction(): bool */ public function detectEnvironment(Closure $callback): string { - $args = $this->runningInConsole() && isset($_SERVER['argv']) - ? $_SERVER['argv'] - : null; + // Server commands use HTTP semantics during boot but still receive startup arguments. + $args = $_SERVER['argv'] ?? null; return $this->instance('env', (new EnvironmentDetector)->detect($callback, $args)); } diff --git a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php index 50c2aedb1b..f7a4dd741e 100644 --- a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php +++ b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php @@ -18,12 +18,12 @@ class LoadEnvironmentVariables */ public function bootstrap(Application $app): void { + $this->checkForSpecificEnvironmentFile($app); + if ($app->configurationIsCached()) { return; } - $this->checkForSpecificEnvironmentFile($app); - try { DotenvManager::safeLoad( [$app->environmentPath()], @@ -39,8 +39,8 @@ public function bootstrap(Application $app): void */ protected function checkForSpecificEnvironmentFile(Application $app): void { - if ($app->runningInConsole() - && ($input = new ArgvInput)->hasParameterOption('--env') + // Match Application::detectEnvironment(), including HTTP-mode server bootstrap. + if (($input = new ArgvInput)->hasParameterOption('--env') && $this->setEnvironmentFilePath($app, $app->environmentFile() . '.' . $input->getParameterOption('--env'))) { return; } diff --git a/src/foundation/src/Console/ConfigCacheCommand.php b/src/foundation/src/Console/ConfigCacheCommand.php index 3bdaca348a..43e7bd793c 100644 --- a/src/foundation/src/Console/ConfigCacheCommand.php +++ b/src/foundation/src/Console/ConfigCacheCommand.php @@ -11,6 +11,7 @@ use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; use Throwable; @@ -118,13 +119,19 @@ protected function getFreshConfigurationCacheContentsFromSubprocess(): string throw new LogicException("The alternate cache path [{$buildPath}] already exists."); } + $arguments = [ + PHP_BINARY, + $this->hypervel->basePath('artisan'), + 'config:cache', + '--dump-to=' . $dumpPath, + ]; + + if (($input = new ArgvInput)->hasParameterOption('--env')) { + $arguments[] = '--env=' . $input->getParameterOption('--env'); + } + $process = new Process( - [ - PHP_BINARY, - $this->hypervel->basePath('artisan'), - 'config:cache', - '--dump-to=' . $dumpPath, - ], + $arguments, $this->hypervel->basePath(), [ 'APP_CONFIG_CACHE' => $buildPath, diff --git a/src/foundation/src/Console/ConfigShowCommand.php b/src/foundation/src/Console/ConfigShowCommand.php index ec33c6de3d..46504e41c7 100644 --- a/src/foundation/src/Console/ConfigShowCommand.php +++ b/src/foundation/src/Console/ConfigShowCommand.php @@ -8,6 +8,7 @@ use Hypervel\Console\Command; use Hypervel\Support\Arr; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'config:show')] class ConfigShowCommand extends Command @@ -15,7 +16,8 @@ class ConfigShowCommand extends Command /** * The console command signature. */ - protected ?string $signature = 'config:show {config : The configuration file or key to show}'; + protected ?string $signature = 'config:show {config : The configuration file or key to show} + {--json : Output the configuration as JSON}'; /** * The console command description. @@ -42,6 +44,15 @@ public function handle(): int $this->fail("Configuration file or key {$config} does not exist."); } + if ($this->option('json')) { + $this->output->writeln( + json_encode($this->config->get($config), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), + OutputInterface::OUTPUT_RAW, + ); + + return self::SUCCESS; + } + $this->newLine(); $this->render($config); $this->newLine(); diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index fe57f774d5..3256541f87 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -423,6 +423,7 @@ public function registerCommand(SymfonyCommand $command): void * Run an Artisan console command by name. * * @throws CommandNotFoundException + * @throws Throwable when application bootstrap or command execution fails */ public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int { diff --git a/src/foundation/src/Console/RouteCacheCommand.php b/src/foundation/src/Console/RouteCacheCommand.php index b4a1900ff5..07b5dcecfc 100644 --- a/src/foundation/src/Console/RouteCacheCommand.php +++ b/src/foundation/src/Console/RouteCacheCommand.php @@ -11,6 +11,7 @@ use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Process\Process; use Throwable; @@ -128,13 +129,19 @@ protected function getFreshCompiledRoutesFromSubprocess(): array throw new LogicException("The alternate cache path [{$buildPath}] already exists."); } + $arguments = [ + PHP_BINARY, + $this->hypervel->basePath('artisan'), + 'route:cache', + '--dump-to=' . $dumpPath, + ]; + + if (($input = new ArgvInput)->hasParameterOption('--env')) { + $arguments[] = '--env=' . $input->getParameterOption('--env'); + } + $process = new Process( - [ - PHP_BINARY, - $this->hypervel->basePath('artisan'), - 'route:cache', - '--dump-to=' . $dumpPath, - ], + $arguments, $this->hypervel->basePath(), [ 'APP_ROUTES_CACHE' => $buildPath, diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php index 68e7425492..5ca1991267 100644 --- a/src/foundation/src/Console/RouteListCommand.php +++ b/src/foundation/src/Console/RouteListCommand.php @@ -18,6 +18,7 @@ use ReflectionException; use ReflectionFunction; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Terminal; #[AsCommand(name: 'route:list')] @@ -185,7 +186,8 @@ protected function displayRoutes(array $routes): void $routes = new Collection($routes); $this->output->writeln( - $this->option('json') ? $this->asJson($routes) : $this->forCli($routes) + $this->option('json') ? $this->asJson($routes) : $this->forCli($routes), + $this->option('json') ? OutputInterface::OUTPUT_RAW : OutputInterface::OUTPUT_NORMAL, ); } diff --git a/src/foundation/src/Console/StorageLinkCommand.php b/src/foundation/src/Console/StorageLinkCommand.php index 0bcab92391..27c4bdf31e 100644 --- a/src/foundation/src/Console/StorageLinkCommand.php +++ b/src/foundation/src/Console/StorageLinkCommand.php @@ -57,10 +57,7 @@ public function handle(): void */ protected function links(): array { - return $this->hypervel->make('config')->array( - 'filesystems.links', - [public_path('storage') => storage_path('app/public')], - ); + return $this->hypervel->make('config')->array('filesystems.links'); } /** diff --git a/src/foundation/src/Console/StorageUnlinkCommand.php b/src/foundation/src/Console/StorageUnlinkCommand.php index 764dbea56b..9086222869 100644 --- a/src/foundation/src/Console/StorageUnlinkCommand.php +++ b/src/foundation/src/Console/StorageUnlinkCommand.php @@ -42,9 +42,6 @@ public function handle(): void */ protected function links(): array { - return $this->hypervel->make('config')->array( - 'filesystems.links', - [public_path('storage') => storage_path('app/public')], - ); + return $this->hypervel->make('config')->array('filesystems.links'); } } diff --git a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php index b139a15477..dd41fd6056 100644 --- a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php +++ b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php @@ -35,8 +35,15 @@ protected function reloadConfig(): void $this->configMutationTracker->replay($config); } + /** + * Reload the selected environment file when configuration is not cached. + */ protected function reloadDotenv(): void { + if (is_file($this->container->getCachedConfigPath())) { + return; + } + DotenvManager::reload( [$this->container->environmentPath()], $this->container->environmentFile(), diff --git a/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php b/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php index 2720039935..b01f3e938e 100644 --- a/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php +++ b/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php @@ -4,34 +4,17 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Redis\RedisRequirements; + /** * Skips tests when the environment doesn't support Redis hash field * expiration. * - * Hash field expiration requires: - * - phpredis >= 6.3.0 (HSETEX command) - * - Redis >= 8.0.0 or Valkey >= 9.0.0 (HEXPIRE command) - * * Expects InteractsWithRedis on the same class — uses redisClient() to * probe the server version. The support check is memoized per process. */ trait RequiresHashFieldExpiration { - /** - * Minimum phpredis version required for hash field expiration. - */ - private const string PHPREDIS_MIN_VERSION = '6.3.0'; - - /** - * Minimum Redis version required for hash field expiration. - */ - private const string REDIS_MIN_VERSION = '8.0.0'; - - /** - * Minimum Valkey version required for hash field expiration. - */ - private const string VALKEY_MIN_VERSION = '9.0.0'; - /** * Cached result of the hash field expiration support check. */ @@ -64,34 +47,12 @@ protected function skipIfHashFieldExpirationUnsupported(): void */ private function checkHashFieldExpirationSupport(): bool { - $phpredisVersion = $this->detectedPhpredisVersion(); - - if (version_compare($phpredisVersion, self::PHPREDIS_MIN_VERSION, '<')) { - self::$hashFieldExpirationSkipReason = 'Hash field expiration requires phpredis >= ' - . self::PHPREDIS_MIN_VERSION . " (installed: {$phpredisVersion})"; - - return false; - } + $error = RedisRequirements::hashFieldExpirationClientError($this->detectedPhpredisVersion()) + ?? RedisRequirements::hashFieldExpirationServerError($this->detectedServerInfo()); - $info = $this->detectedServerInfo(); - - if (isset($info['valkey_version'])) { - if (version_compare($info['valkey_version'], self::VALKEY_MIN_VERSION, '<')) { - self::$hashFieldExpirationSkipReason = 'Hash field expiration requires Valkey >= ' - . self::VALKEY_MIN_VERSION . " (installed: {$info['valkey_version']})"; - - return false; - } - } elseif (isset($info['redis_version'])) { - if (version_compare($info['redis_version'], self::REDIS_MIN_VERSION, '<')) { - self::$hashFieldExpirationSkipReason = 'Hash field expiration requires Redis >= ' - . self::REDIS_MIN_VERSION . " (installed: {$info['redis_version']})"; - - return false; - } - } + self::$hashFieldExpirationSkipReason = $error ?? ''; - return true; + return $error === null; } /** diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index a82c4be053..d84d90ffbd 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -14,10 +14,12 @@ use Pheanstalk\Contract\PheanstalkManagerInterface; use Pheanstalk\Contract\PheanstalkPublisherInterface; use Pheanstalk\Contract\PheanstalkSubscriberInterface; +use Pheanstalk\Exception\TubeNotFoundException; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Job; use Pheanstalk\Values\JobId; use Pheanstalk\Values\TubeName; +use Pheanstalk\Values\TubeStats; use UnitEnum; class BeanstalkdQueue extends Queue implements QueueContract @@ -45,7 +47,11 @@ public function __construct( */ public function size(UnitEnum|string|null $queue = null): int { - $stats = $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue))); + $stats = $this->tubeStats($queue); + + if ($stats === null) { + return 0; + } return $stats->currentJobsReady + $stats->currentJobsDelayed @@ -57,7 +63,7 @@ public function size(UnitEnum|string|null $queue = null): int */ public function pendingSize(UnitEnum|string|null $queue = null): int { - return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue)))->currentJobsReady; + return $this->tubeStats($queue)->currentJobsReady ?? 0; } /** @@ -65,7 +71,7 @@ public function pendingSize(UnitEnum|string|null $queue = null): int */ public function delayedSize(UnitEnum|string|null $queue = null): int { - return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue)))->currentJobsDelayed; + return $this->tubeStats($queue)->currentJobsDelayed ?? 0; } /** @@ -73,7 +79,19 @@ public function delayedSize(UnitEnum|string|null $queue = null): int */ public function reservedSize(UnitEnum|string|null $queue = null): int { - return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue)))->currentJobsReserved; + return $this->tubeStats($queue)->currentJobsReserved ?? 0; + } + + /** + * Get tube statistics, treating a tube that has not been created as empty. + */ + protected function tubeStats(UnitEnum|string|null $queue): ?TubeStats + { + try { + return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue))); + } catch (TubeNotFoundException) { + return null; + } } /** diff --git a/src/queue/src/Console/ListFailedCommand.php b/src/queue/src/Console/ListFailedCommand.php index 23678d02d5..c1fde22fe4 100644 --- a/src/queue/src/Console/ListFailedCommand.php +++ b/src/queue/src/Console/ListFailedCommand.php @@ -9,6 +9,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'queue:failed')] class ListFailedCommand extends Command @@ -134,6 +135,6 @@ protected function displayFailedJobsAsJson(array $jobs): void 'queue' => $job[2], 'class' => $job[3], 'failed_at' => $job[4], - ])->toJson()); + ])->toJson(), OutputInterface::OUTPUT_RAW); } } diff --git a/src/queue/src/Console/MonitorCommand.php b/src/queue/src/Console/MonitorCommand.php index 5af555920c..ecce630ce2 100644 --- a/src/queue/src/Console/MonitorCommand.php +++ b/src/queue/src/Console/MonitorCommand.php @@ -13,6 +13,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'queue:monitor')] class MonitorCommand extends Command @@ -51,7 +52,7 @@ public function handle(): void if ($this->option('json')) { $this->output->writeln($queues->map(fn (array $queue): array => array_merge($queue, [ 'status' => str_contains($queue['status'], 'ALERT') ? 'ALERT' : 'OK', - ]))->toJson()); + ]))->toJson(), OutputInterface::OUTPUT_RAW); } else { $this->displaySizes($queues); } diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index f912c159c2..a39c734775 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -250,7 +250,7 @@ protected function writeQueueStatus(string $queue, string $status): void 'queue' => $queue, 'status' => $status, 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'), - ])); + ]), OutputInterface::OUTPUT_RAW); return; } diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 904f4d7796..cb4836a49a 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -21,14 +21,17 @@ use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; +use WeakReference; + +use function Hypervel\Support\enum_value; class FailoverQueue extends Queue implements QueueContract, IndexAwareQueue { /** * Context key prefix for the queues which failed on the last action. * - * Scoped per instance via spl_object_id() so multiple failover queues - * in the same coroutine don't share failure history. + * Entries pair a weak owner with its failure list. A dead or different owner + * means no history, since released object IDs may be reused within a coroutine. * * Stored in coroutine Context instead of an instance property because this * queue is cached in QueueManager::$connections. Instance state would leak @@ -246,7 +249,8 @@ protected function attemptOnAllConnections(string $method, array $arguments, obj } $contextKey = self::FAILING_QUEUES_CONTEXT_PREFIX . spl_object_id($this); - $failingQueues = CoroutineContext::get($contextKey, []); + $state = CoroutineContext::get($contextKey); + $failingQueues = $state !== null && $state[0]->get() === $this ? $state[1] : []; [$lastException, $failedQueues] = [null, []]; @@ -265,12 +269,16 @@ protected function attemptOnAllConnections(string $method, array $arguments, obj && ! in_array($connection, $failingQueues, true) && $this->events->hasListeners(QueueFailedOver::class) ) { - $this->events->dispatch(new QueueFailedOver($connection, $job, $e)); + $this->events->dispatch(new QueueFailedOver( + $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection, + $job, + $e, + )); } } } } finally { - CoroutineContext::set($contextKey, $failedQueues); + CoroutineContext::set($contextKey, [WeakReference::create($this), $failedQueues]); } throw $lastException ?? new RuntimeException('All failover queue connections failed.'); diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 5ed9aada16..7edbff1dbc 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -15,6 +15,7 @@ use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; +use Hypervel\Queue\Connectors\FailoverConnector; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; @@ -409,10 +410,17 @@ protected function resolve(string $name): QueueContract } $constructionConfig = Arr::except($config, ['pool']); - $createCallback = fn () => $this->getConnector($config['driver']) - ->connect($constructionConfig) - ->setContainer($this->app) // @phpstan-ignore method.notFound (setContainer is on concrete Queue, not contract) - ->setConfig($constructionConfig); + $createCallback = function () use ($config, $constructionConfig): QueueContract { + $connector = $this->getConnector($config['driver']); + + if ($connector instanceof FailoverConnector) { + $this->ensureConnectionsAreAcyclic($constructionConfig, $config['driver']); + } + + return $connector->connect($constructionConfig) + ->setContainer($this->app) // @phpstan-ignore method.notFound (setContainer is on concrete Queue, not contract) + ->setConfig($constructionConfig); + }; if (in_array($config['driver'], $this->poolableDrivers, true)) { /** @var QueuePoolProxy $proxy */ @@ -429,6 +437,39 @@ protected function resolve(string $name): QueueContract return $createCallback()->setConnectionName($name); } + /** + * Reject circular definitions owned by the resolved failover connector. + * + * @param list $path + */ + protected function ensureConnectionsAreAcyclic(array $config, string $driver, array $path = []): void + { + if (($config['driver'] ?? null) !== $driver || ! is_array($config['connections'] ?? null)) { + return; + } + + foreach ($config['connections'] as $name) { + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + $name = $name === null || $name === '' ? $this->getDefaultDriver() : $name; + + if (! is_string($name)) { + continue; + } + + if (($cycleStart = array_search($name, $path, true)) !== false) { + $cycle = [...array_slice($path, $cycleStart), $name]; + + throw new InvalidArgumentException( + 'Circular queue connection definition detected: ' . implode(' -> ', $cycle) . '.' + ); + } + + if (($child = $this->getConfig($name)) !== null) { + $this->ensureConnectionsAreAcyclic($child, $driver, [...$path, $name]); + } + } + } + /** * Get the connector for a given driver. * diff --git a/src/redis/src/RedisRequirements.php b/src/redis/src/RedisRequirements.php new file mode 100644 index 0000000000..07dfe5bf3e --- /dev/null +++ b/src/redis/src/RedisRequirements.php @@ -0,0 +1,65 @@ += ' + . self::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION . " (installed: {$version})"; + } + + return null; + } + + /** + * Get the reason a server cannot support hash field expiration. + * + * @param array $info + */ + public static function hashFieldExpirationServerError(array $info): ?string + { + if (isset($info['valkey_version'])) { + if (version_compare($info['valkey_version'], self::HASH_FIELD_EXPIRATION_VALKEY_VERSION, '<')) { + return 'Hash field expiration requires Valkey >= ' + . self::HASH_FIELD_EXPIRATION_VALKEY_VERSION . " (installed: {$info['valkey_version']})"; + } + } elseif (isset($info['redis_version'])) { + if (version_compare($info['redis_version'], self::HASH_FIELD_EXPIRATION_REDIS_VERSION, '<')) { + return 'Hash field expiration requires Redis >= ' + . self::HASH_FIELD_EXPIRATION_REDIS_VERSION . " (installed: {$info['redis_version']})"; + } + } else { + return 'Could not determine Redis/Valkey version for hash field expiration.'; + } + + return null; + } +} diff --git a/src/session/src/SessionManager.php b/src/session/src/SessionManager.php index ba89b5efeb..5dd9a13434 100644 --- a/src/session/src/SessionManager.php +++ b/src/session/src/SessionManager.php @@ -22,6 +22,8 @@ */ class SessionManager extends Manager { + public const string DEFAULT_REDIS_CONNECTION = 'session'; + /** * Call a custom driver creator. */ @@ -112,7 +114,7 @@ protected function createRedisDriver(): Store $connection = $this->config->get('session.connection'); if ($connection === null || $connection === '') { - $connection = 'session'; + $connection = self::DEFAULT_REDIS_CONNECTION; } return $this->buildSession(new RedisSessionHandler( diff --git a/src/support/src/DotenvManager.php b/src/support/src/DotenvManager.php index 3a87e36574..82628aa852 100644 --- a/src/support/src/DotenvManager.php +++ b/src/support/src/DotenvManager.php @@ -11,10 +11,20 @@ class DotenvManager /** * The keys and values loaded from the last load/reload call. * - * @var null|array + * @var null|array */ protected static ?array $cachedValues = null; + /** + * Get the names loaded from dotenv, excluding external environment variables. + * + * @return list + */ + public static function getLoadedVariableNames(): array + { + return array_keys(static::$cachedValues ?? []); + } + /** * Load environment variables from the given paths. * diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php index 980f79742c..2b37906ae4 100644 --- a/src/support/src/Facades/Facade.php +++ b/src/support/src/Facades/Facade.php @@ -23,6 +23,7 @@ use Mockery\LegacyMockInterface; use Mockery\MockInterface; use RuntimeException; +use Throwable; abstract class Facade { @@ -352,6 +353,7 @@ public static function setFacadeApplication(?ContainerContract $app): void * Handle dynamic, static calls to the object. * * @throws RuntimeException + * @throws Throwable when the resolved service throws */ public static function __callStatic(string $method, array $args): mixed { diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 85eef518d0..3e2810459d 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": "^8.4", - "composer/semver": "^3.4", + "composer/semver": "^3.5.1", "composer-runtime-api": "^2.2", "ext-posix": "*", "mockery/mockery": "^1.6.15", diff --git a/src/testbench/hypervel/artisan b/src/testbench/hypervel/artisan index 37429c03d0..3d108b03f5 100644 --- a/src/testbench/hypervel/artisan +++ b/src/testbench/hypervel/artisan @@ -57,8 +57,7 @@ $httpBootstrapCommands = ['serve', 'watch']; $input = new ArgvInput; if (in_array(ConsoleApplication::resolveCommandName($input), $httpBootstrapCommands, true)) { - putenv('APP_RUNNING_IN_CONSOLE=false'); - $_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; + // Forked server workers inherit this flag; separate Artisan processes must not. $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; } diff --git a/src/testbench/src/Console/Commander.php b/src/testbench/src/Console/Commander.php index 17b7d3a68c..174f3e00af 100644 --- a/src/testbench/src/Console/Commander.php +++ b/src/testbench/src/Console/Commander.php @@ -351,8 +351,7 @@ protected function prepareCommandEnvironment(ArgvInput $input): void return; } - putenv('APP_RUNNING_IN_CONSOLE=false'); - $_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; + // Forked server workers inherit this flag; separate Artisan processes must not. $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 0b4f3b0b09..d060f12e87 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -333,6 +333,7 @@ protected function flushFrameworkState(): void $this->flushAiState(); $this->flushDataState(); + $this->flushDoctorState(); $this->flushFortifyState(); $this->flushHeadState(); $this->flushHorizonState(); @@ -376,6 +377,14 @@ protected function flushDataState(): void $this->callIfExists(\Hypervel\Data\PaginatedDataCollection::class, 'flushMacros'); } + /** + * Flush Doctor state. + */ + protected function flushDoctorState(): void + { + $this->callIfExists(\Hypervel\Doctor\DiagnosticSource::class, 'flushState'); + } + /** * Flush Fortify state. */ diff --git a/tests/Cache/CacheFailoverStoreTest.php b/tests/Cache/CacheFailoverStoreTest.php index f38723e8fd..da10d5d876 100644 --- a/tests/Cache/CacheFailoverStoreTest.php +++ b/tests/Cache/CacheFailoverStoreTest.php @@ -537,6 +537,27 @@ function (CacheFailedOver $event) use (&$failedStores, $listenerFailure): void { $this->assertSame(['third', 'first', 'second'], $failedStores); } + public function testReplacementStoreReportsItsOwnFirstFailure(): void + { + $cache = m::mock(CacheManager::class); + $events = m::mock(Dispatcher::class); + $primary = m::mock(RepositoryContract::class); + $secondary = m::mock(RepositoryContract::class); + $primary->allows('get')->andThrow(new RuntimeException('Primary unavailable.')); + $secondary->allows('get')->andReturn('value'); + $cache->allows('store')->with('primary')->andReturn($primary); + $cache->allows('store')->with('secondary')->andReturn($secondary); + $events->allows('hasListeners')->with(CacheFailedOver::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->twice()->with(m::type(CacheFailedOver::class)); + + $store = new FailoverStore($cache, $events, ['primary', 'secondary']); + $this->assertSame('value', $store->get('key')); + unset($store); + + $replacement = new FailoverStore($cache, $events, ['primary', 'secondary']); + $this->assertSame('value', $replacement->get('key')); + } + public function testFailoverEventCarriesLogicalAndFailedBackingStoreNames(): void { $exception = new RuntimeException('primary unavailable'); diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index 2235fb26b8..819814af36 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -38,6 +38,7 @@ use InvalidArgumentException; use Mockery as m; use Mockery\MockInterface; +use PHPUnit\Framework\Attributes\DataProvider; use Redis; use ReflectionProperty; use RuntimeException; @@ -183,6 +184,106 @@ public function testFailoverDriverPassesItsConfiguredNameToFailoverEvents(): voi $this->assertSame($failure, $captured->exception); } + #[DataProvider('circularStores')] + public function testRejectsCircularStoreDefinitions(array $stores, string $cycle): void + { + $manager = new CacheManager($this->getApp(['cache' => ['stores' => $stores]])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('Circular cache store definition detected: ' . $cycle . '.'); + + $manager->store('main'); + } + + /** + * Provide recursive built-in store definitions. + */ + public static function circularStores(): array + { + return [ + 'self stack' => [[ + 'main' => ['driver' => 'stack', 'stores' => ['main']], + ], 'main -> main'], + 'self failover' => [[ + 'main' => ['driver' => 'failover', 'stores' => ['main']], + ], 'main -> main'], + 'nested mixed cycle' => [[ + 'main' => ['driver' => 'stack', 'stores' => ['first']], + 'first' => ['driver' => 'failover', 'stores' => ['second']], + 'second' => ['driver' => 'stack', 'stores' => ['first' => ['ttl' => 60]]], + ], 'first -> second -> first'], + ]; + } + + #[DataProvider('acyclicStores')] + public function testNestedAndSharedStoresRemainUsable(array $stores): void + { + $app = $this->getApp(['cache' => ['stores' => $stores]]); + $app->instance(Dispatcher::class, new Event); + $manager = new CacheManager($app); + $repository = $manager->store('main'); + + $this->assertTrue($repository->put('key', 'value', 60)); + $this->assertSame('value', $repository->get('key')); + } + + /** + * Provide valid nested and shared store definitions. + */ + public static function acyclicStores(): array + { + return [ + 'nested' => [[ + 'main' => ['driver' => 'failover', 'stores' => ['nested']], + 'nested' => ['driver' => 'stack', 'stores' => ['leaf' => ['ttl' => 60]]], + 'leaf' => ['driver' => 'array'], + ]], + 'shared child' => [[ + 'main' => ['driver' => 'stack', 'stores' => ['first', 'second']], + 'first' => ['driver' => 'failover', 'stores' => ['leaf']], + 'second' => ['driver' => 'failover', 'stores' => ['leaf']], + 'leaf' => ['driver' => 'array'], + ]], + ]; + } + + public function testOnDemandWrapperNameCanMatchItsChild(): void + { + $manager = new CacheManager($this->getApp(['cache' => ['stores' => [ + 'array' => ['driver' => 'array'], + ]]])); + $repository = $manager->build(['driver' => 'stack', 'name' => 'array', 'stores' => ['array']]); + + $this->assertTrue($repository->put('key', 'value', 60)); + $this->assertSame('value', $repository->get('key')); + } + + public function testWrapperValidationRespectsCustomCreators(): void + { + $manager = new CacheManager($this->getApp(['cache' => ['stores' => [ + 'main' => ['driver' => 'stack', 'stores' => ['custom']], + 'custom' => ['driver' => 'failover', 'stores' => ['custom']], + ]]])); + $manager->extend('failover', fn (): Repository => new Repository(new ArrayStore)); + $repository = $manager->store('main'); + + $this->assertTrue($repository->put('key', 'value', 60)); + $this->assertSame('value', $repository->get('key')); + } + + public function testMissingFailoverBackupRemainsDeferred(): void + { + $app = $this->getApp(['cache' => ['stores' => [ + 'main' => ['driver' => 'failover', 'stores' => ['healthy', 'missing']], + 'healthy' => ['driver' => 'array'], + ]]]); + $app->instance(Dispatcher::class, new Event); + $repository = (new CacheManager($app))->store('main'); + + $this->assertTrue($repository->put('key', 'value', 60)); + $this->assertSame('value', $repository->get('key')); + } + public function testManagerBuiltSerializingStoresShareOnePolicy(): void { $app = $this->getAppWithRedis([ diff --git a/tests/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php index e404f5d979..580fe674ff 100644 --- a/tests/Cache/Redis/Console/DoctorCommandTest.php +++ b/tests/Cache/Redis/Console/DoctorCommandTest.php @@ -10,6 +10,7 @@ use Hypervel\Cache\Redis\Console\Doctor\CheckResult; use Hypervel\Cache\Redis\Console\Doctor\Checks\HashFieldExpirationCheck; use Hypervel\Cache\Redis\Console\Doctor\Checks\PhpRedisCheck; +use Hypervel\Cache\Redis\Console\Doctor\Checks\RedisVersionCheck; use Hypervel\Cache\Redis\Console\Doctor\DoctorContext; use Hypervel\Cache\Redis\Console\DoctorCommand; use Hypervel\Cache\Redis\Support\StoreContext; @@ -80,6 +81,38 @@ public static function phpRedisRequirements(): array ]; } + #[DataProvider('serverRequirements')] + public function testRedisVersionCheckUsesFeatureRequirement(string $taggingMode, array $info, bool $supported): void + { + $connection = m::mock(RedisConnection::class); + $connection->allows('info')->with('server')->andReturn($info); + + $check = new RedisVersionCheck($connection, $taggingMode); + + $this->assertSame($supported, $check->run()->passed()); + + if ($supported || $info === []) { + $this->assertNull($check->getFixInstructions()); + } else { + $this->assertStringContainsString('Upgrade to', $check->getFixInstructions()); + } + } + + /** + * Provide ordinary and hash-field-expiration server requirements. + */ + public static function serverRequirements(): array + { + return [ + 'ordinary Redis' => ['all', ['redis_version' => '7.0.0'], true], + 'old Redis for any mode' => ['any', ['redis_version' => '7.0.0'], false], + 'supported Redis' => ['any', ['redis_version' => '8.0.0'], true], + 'Valkey precedence' => ['any', ['valkey_version' => '9.0.0', 'redis_version' => '7.2.0'], true], + 'old Valkey' => ['any', ['valkey_version' => '8.0.0', 'redis_version' => '8.0.0'], false], + 'unknown server' => ['any', [], false], + ]; + } + public function testHashFieldExpirationCheckSkipsAllMode(): void { $connection = m::mock(RedisConnection::class); @@ -153,7 +186,7 @@ public function testHashFieldExpirationCheckReportsFixInstructionsWhenProbeFails ], ], $result->assertions); $this->assertSame( - 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.', + 'Any tagging mode requires Redis 8.0.0+ or Valkey 9.0.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.', $check->getFixInstructions() ); } @@ -185,7 +218,7 @@ public function testHashFieldExpirationCheckReportsFailureWhenProbeReturnsFalse( ], ], $result->assertions); $this->assertSame( - 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.', + 'Any tagging mode requires Redis 8.0.0+ or Valkey 9.0.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.', $check->getFixInstructions() ); } diff --git a/tests/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php index 09fb62e38b..fdb3b275c1 100644 --- a/tests/Concurrency/ConcurrencyTest.php +++ b/tests/Concurrency/ConcurrencyTest.php @@ -694,18 +694,35 @@ public static function falseyExceptionParameters(): array } #[UsesVendor] - public function testContextIsPropagatedToConcurrentProcesses(): void + #[DataProvider('parentConsoleModes')] + public function testContextIsPropagatedToConcurrentProcesses(bool $runningInConsole): void { - Context::add('task', 'concurrency'); - Context::addHidden('token', 'secret'); + $this->withEnvironmentValue('APP_RUNNING_IN_CONSOLE', null, function () use ($runningInConsole): void { + $_SERVER['APP_RUNNING_IN_CONSOLE'] = $runningInConsole ? 'true' : 'false'; + $this->app->setRunningInConsole($runningInConsole); - [$task, $token] = ConcurrencyFacade::driver('process')->run([ - static fn (): mixed => Context::get('task'), - static fn (): mixed => Context::getHidden('token'), - ]); + Context::add('task', 'concurrency'); + Context::addHidden('token', 'secret'); + + [$task, $token] = ConcurrencyFacade::driver('process')->run([ + static fn (): mixed => Context::get('task'), + static fn (): mixed => Context::getHidden('token'), + ]); + + $this->assertSame('concurrency', $task); + $this->assertSame('secret', $token); + }); + } - $this->assertSame('concurrency', $task); - $this->assertSame('secret', $token); + /** + * Provide parent application modes for process context propagation. + */ + public static function parentConsoleModes(): array + { + return [ + 'console' => [true], + 'HTTP server' => [false], + ]; } public function testContextIsPropagatedToDeferredConcurrentProcesses(): void diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index daf871e736..18d0c45234 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -1711,6 +1711,19 @@ 'description' => 'The configuration file or key to show', ], ], + 'options' => [ + [ + 'name' => 'json', + 'shortcut' => null, + 'negatable' => false, + 'valueRequired' => false, + 'valueOptional' => false, + 'isArray' => false, + 'acceptValue' => false, + 'default' => false, + 'description' => 'Output the configuration as JSON', + ], + ], ], \Hypervel\Foundation\Console\ConsoleMakeCommand::class => [ 'name' => 'make:command', diff --git a/tests/Database/DatabaseMigratorIntegrationTest.php b/tests/Database/DatabaseMigratorIntegrationTest.php index df8de34fc3..11177fb216 100644 --- a/tests/Database/DatabaseMigratorIntegrationTest.php +++ b/tests/Database/DatabaseMigratorIntegrationTest.php @@ -43,10 +43,11 @@ protected function defineEnvironment(Application $app): void ]); } - protected function setUp(): void + /** + * Set up migration state in the test coroutine. + */ + protected function setUpInCoroutine(): void { - parent::setUp(); - $databaseManager = $this->app->make('db'); $this->migrator = new Migrator( diff --git a/tests/Database/MigratorCoroutineIsolationTest.php b/tests/Database/MigratorCoroutineIsolationTest.php new file mode 100644 index 0000000000..932a3d2be4 --- /dev/null +++ b/tests/Database/MigratorCoroutineIsolationTest.php @@ -0,0 +1,217 @@ +migrator(); + $migrator->path('/shared/migrations'); + + run(function () use ($migrator, $repository): void { + $tasks = []; + + foreach (['first', 'second'] as $name) { + $tasks[] = function () use ($migrator, $repository, $name): string { + $output = new BufferedOutput; + $migrator->setOutput(new OutputStyle(new ArrayInput([]), $output)); + $migrator->setConnection('before-' . $name); + CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, 'ambient-' . $name); + $failure = new RuntimeException('Migration failed.'); + + try { + $migrator->usingConnection($name, function () use ($migrator, $repository, $name, $failure): void { + usleep(5000); + + $this->assertSame($name, $migrator->getConnection()); + $this->assertSame($name, $migrator->resolveConnection(null)->getName()); + $this->assertSame($name, $repository->getConnection()->getName()); + $this->assertSame($name, CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY)); + $this->assertSame(['/shared/migrations'], $migrator->paths()); + $migrator->runPending([]); + + if ($name === 'second') { + throw $failure; + } + }); + + if ($name === 'second') { + $this->fail('The migration failure must propagate.'); + } + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame('before-' . $name, $migrator->getConnection()); + $this->assertSame('before-' . $name, $repository->getConnection()->getName()); + $this->assertSame('ambient-' . $name, CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY)); + + return $output->fetch(); + }; + } + + foreach (parallel($tasks) as $output) { + $this->assertSame(1, substr_count($output, 'Nothing to migrate')); + } + + $this->assertNull($migrator->getConnection()); + $this->assertSame('default', $repository->getConnection()->getName()); + }); + } + + public function testForkedRunsInheritStateWithoutChangingParentAssignments(): void + { + [$migrator, $repository] = $this->migrator(); + + run(function () use ($migrator, $repository): void { + $parentOutput = new BufferedOutput; + $migrator->setConnection('parent'); + $migrator->setOutput(new OutputStyle(new ArrayInput([]), $parentOutput)); + + [$childOutput] = parallel([ + function () use ($migrator, $repository): string { + $this->assertSame('parent', $migrator->getConnection()); + $this->assertSame('parent', $repository->getConnection()->getName()); + $migrator->runPending([]); + + $output = new BufferedOutput; + $migrator->setOutput(new OutputStyle(new ArrayInput([]), $output)); + $migrator->setConnection('child'); + usleep(5000); + $migrator->runPending([]); + $this->assertSame('child', $repository->getConnection()->getName()); + + return $output->fetch(); + }, + ], copyContext: true); + + $this->assertSame('parent', $migrator->getConnection()); + $this->assertSame('parent', $repository->getConnection()->getName()); + $this->assertSame('parent', CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY)); + $migrator->runPending([]); + $this->assertSame(2, substr_count($parentOutput->fetch(), 'Nothing to migrate')); + $this->assertSame(1, substr_count($childOutput, 'Nothing to migrate')); + }); + } + + public function testLiveInstancesKeepTheirOwnSelectedConnections(): void + { + [$first, $firstRepository] = $this->migrator(); + [$second, $secondRepository] = $this->migrator(); + + run(function () use ($first, $second, $firstRepository, $secondRepository): void { + $first->setConnection('first'); + $second->setConnection('second'); + + $this->assertSame('first', $first->getConnection()); + $this->assertSame('first', $firstRepository->getConnection()->getName()); + $this->assertSame('second', $second->getConnection()); + $this->assertSame('second', $secondRepository->getConnection()->getName()); + + $firstRepository->setSource('install'); + $this->assertSame('install', $firstRepository->getConnection()->getName()); + $this->assertSame('second', $secondRepository->getConnection()->getName()); + }); + } + + public function testNonCoroutineStateIsPreservedAcrossIndependentCoroutineRuns(): void + { + [$migrator, $repository] = $this->migrator(); + $output = new BufferedOutput; + $migrator->setConnection('outside'); + $migrator->setOutput(new OutputStyle(new ArrayInput([]), $output)); + + run(function () use ($migrator, $repository): void { + $this->assertNull($migrator->getConnection()); + $this->assertSame('default', $repository->getConnection()->getName()); + $migrator->setConnection('inside'); + $migrator->runPending([]); + }); + + $this->assertSame('outside', $migrator->getConnection()); + $this->assertSame('outside', $repository->getConnection()->getName()); + $this->assertSame('', $output->fetch()); + $migrator->runPending([]); + $this->assertStringContainsString('Nothing to migrate', $output->fetch()); + } + + public function testReplacementMigratorDoesNotInheritReleasedInstanceState(): void + { + run(function (): void { + $resolver = m::mock(ConnectionResolverInterface::class); + $repository = new DatabaseMigrationRepository($resolver, 'migrations'); + $files = new Filesystem; + $output = new BufferedOutput; + $migrator = new Migrator($repository, $resolver, $files); + $migrator->setConnection('previous'); + $migrator->setOutput(new OutputStyle(new ArrayInput([]), $output)); + unset($migrator); + + $replacement = new Migrator($repository, $resolver, $files); + + $this->assertNull($replacement->getConnection()); + $replacement->runPending([]); + $this->assertSame('', $output->fetch()); + $replacement->setConnection('replacement'); + $this->assertSame('replacement', $replacement->getConnection()); + }); + } + + public function testReplacementRepositoryDoesNotInheritReleasedInstanceConnection(): void + { + run(function (): void { + $connection = m::mock(Connection::class); + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->expects('connection')->with(null)->andReturn($connection); + $repository = new DatabaseMigrationRepository($resolver, 'migrations'); + $repository->setSource('previous'); + unset($repository); + + $replacement = new DatabaseMigrationRepository($resolver, 'migrations'); + + $this->assertSame($connection, $replacement->getConnection()); + }); + } + + /** + * Build a real migrator and repository with named connection doubles. + * + * @return array{Migrator, DatabaseMigrationRepository} + */ + private function migrator(): array + { + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->allows('connection')->andReturnUsing(static function (?string $name): Connection { + $connection = m::mock(Connection::class); + $connection->allows('getName')->andReturn($name ?? 'default'); + + return $connection; + }); + + $repository = new DatabaseMigrationRepository($resolver, 'migrations'); + + return [new Migrator($repository, $resolver, new Filesystem), $repository]; + } +} diff --git a/tests/Database/MigratorTest.php b/tests/Database/MigratorTest.php index 08c3159733..42824f654e 100644 --- a/tests/Database/MigratorTest.php +++ b/tests/Database/MigratorTest.php @@ -35,12 +35,10 @@ protected function defineEnvironment(Application $app): void } /** - * Set up the test environment. + * Set up migration state in the test coroutine. */ - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - $this->output = m::mock(OutputStyle::class); $this->output->shouldReceive('newLinesWritten')->andReturn(0); $this->subject = $this->app->make('migrator'); diff --git a/tests/Doctor/ArchTest.php b/tests/Doctor/ArchTest.php new file mode 100644 index 0000000000..754d7a20d7 --- /dev/null +++ b/tests/Doctor/ArchTest.php @@ -0,0 +1,75 @@ +assertNoFunctionCalls(['dd', 'dump', 'var_dump', 'die', 'ray']); + } + + public function testNoDirectEnvCalls(): void + { + $this->assertNoFunctionCalls(['env']); + } + + /** + * Read the package's PHP source files. + * + * @return iterable + */ + private function sourceFiles(): iterable + { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../../src/doctor/src')); + + foreach ($files as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + yield $file->getPathname() => file_get_contents($file->getPathname()); + } + } + } + + /** + * Reject calls to prohibited functions without matching comments or strings. + * + * @param array $functions + */ + private function assertNoFunctionCalls(array $functions): void + { + $violations = []; + + foreach ($this->sourceFiles() as $path => $source) { + $tokens = array_values(array_filter(token_get_all($source), static fn (array|string $token): bool => ! is_array($token) + || ! in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true))); + + foreach ($tokens as $index => $token) { + if (! is_array($token)) { + continue; + } + + $previous = $tokens[$index - 1] ?? null; + + if (is_array($previous) && in_array($previous[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW], true)) { + continue; + } + + if ($token[0] === T_EXIT && in_array('die', $functions, true)) { + $violations[] = $path . ':' . $token[2]; + } elseif (in_array($token[0], [T_STRING, T_NAME_FULLY_QUALIFIED], true) + && in_array(strtolower(ltrim($token[1], '\\')), $functions, true) + && ($tokens[$index + 1] ?? null) === '(') { + $violations[] = $path . ':' . $token[2]; + } + } + } + + $this->assertSame([], $violations); + } +} diff --git a/tests/Doctor/Feature/Console/DiagnosticMakeCommandTest.php b/tests/Doctor/Feature/Console/DiagnosticMakeCommandTest.php new file mode 100644 index 0000000000..a1e3b79ea2 --- /dev/null +++ b/tests/Doctor/Feature/Console/DiagnosticMakeCommandTest.php @@ -0,0 +1,116 @@ +artisan('make:diagnostic', ['name' => 'HorizonIsRunning']) + ->expectsConfirmation('Should the diagnostic offer a fix?') + ->assertExitCode(0); + + $source = File::get(app_path('Doctor/Diagnostics/HorizonIsRunning.php')); + $this->assertStringContainsString('declare(strict_types=1);', $source); + $this->assertStringContainsString('namespace App\Doctor\Diagnostics;', $source); + $this->assertStringContainsString('class HorizonIsRunning extends Diagnostic', $source); + $this->assertStringContainsString('public function check(): DiagnosticResult', $source); + $this->assertStringNotContainsString('Fixable', $source); + } + + public function testGeneratesAFixableDiagnosticClass(): void + { + $this->artisan('make:diagnostic', ['name' => 'HorizonIsRunning', '--fixable' => true]) + ->assertExitCode(0); + + $source = File::get(app_path('Doctor/Diagnostics/HorizonIsRunning.php')); + $this->assertStringContainsString('declare(strict_types=1);', $source); + $this->assertStringContainsString('use Hypervel\Doctor\Contracts\Fixable;', $source); + $this->assertStringContainsString('class HorizonIsRunning extends Diagnostic implements Fixable', $source); + $this->assertStringContainsString('->fixable()', $source); + $this->assertStringContainsString('public function fix(DiagnosticResult $result, ?string $option = null): FixResult', $source); + } + + public function testPromptsForTheNameAndFixabilityWhenNoInputIsGiven(): void + { + $this->artisan('make:diagnostic') + ->expectsQuestion('What should the diagnostic be named?', 'QueueWorkerIsRunning') + ->expectsConfirmation('Should the diagnostic offer a fix?', 'yes') + ->assertExitCode(0); + + $this->assertStringContainsString('class QueueWorkerIsRunning extends Diagnostic implements Fixable', File::get(app_path('Doctor/Diagnostics/QueueWorkerIsRunning.php'))); + } + + public function testDoesNotPromptForFixabilityWithoutAnInteractiveTerminal(): void + { + $this->artisan('make:diagnostic', ['name' => 'MailerIsConfigured', '--no-interaction' => true]) + ->assertExitCode(0); + + $this->assertStringNotContainsString('Fixable', File::get(app_path('Doctor/Diagnostics/MailerIsConfigured.php'))); + } + + public function testDoesNotOverwriteAnExistingDiagnosticWithoutForce(): void + { + $this->artisan('make:diagnostic', ['name' => 'ExistingDiagnostic']) + ->expectsConfirmation('Should the diagnostic offer a fix?') + ->assertExitCode(0); + + File::put(app_path('Doctor/Diagnostics/ExistingDiagnostic.php'), 'artisan('make:diagnostic', ['name' => 'ExistingDiagnostic', '--fixable' => true]) + ->expectsOutputToContain('already exists'); + + $this->assertSame('artisan('make:diagnostic', ['name' => 'GeneratedStandardDiagnostic']) + ->expectsConfirmation('Should the diagnostic offer a fix?') + ->assertExitCode(0); + + require app_path('Doctor/Diagnostics/GeneratedStandardDiagnostic.php'); + + $diagnostic = new GeneratedStandardDiagnostic; + + $this->assertSame('Generated standard diagnostic', $diagnostic->name); + $this->assertSame('generated-standard-diagnostic', $diagnostic->group); + $this->assertSame(Status::Pass, $diagnostic->check()->status); + } + + public function testGeneratesAFixableDiagnosticThatRunsAndFixes(): void + { + $this->artisan('make:diagnostic', ['name' => 'GeneratedFixableDiagnostic', '--fixable' => true]) + ->assertExitCode(0); + + require app_path('Doctor/Diagnostics/GeneratedFixableDiagnostic.php'); + + $diagnostic = new GeneratedFixableDiagnostic; + $result = $diagnostic->check(); + + $this->assertSame(Status::Fail, $result->status); + $this->assertTrue($result->fixable); + $this->assertSame('Apply the fix?', $result->confirmation); + $this->assertSame(Status::Pass, $diagnostic->fix($result)->status); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ApplicationKeyIsSetTest.php b/tests/Doctor/Feature/Diagnostics/ApplicationKeyIsSetTest.php new file mode 100644 index 0000000000..28fba66cbc --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ApplicationKeyIsSetTest.php @@ -0,0 +1,239 @@ +detectEnvironment(fn (): string => 'local'); + } + + /** + * Use the disposable application's environment file for fresh boots. + */ + protected function setUp(): void + { + parent::setUp(); + $this->environmentPath = $this->app->basePath(); + $path = $this->environmentPath . '/.env'; + $this->originalEnvironmentFile = is_file($path) ? File::get($path) : null; + File::delete($path); + $this->app->useEnvironmentPath($this->environmentPath); + } + + /** + * Remove the environment fixture. + */ + protected function tearDown(): void + { + try { + $path = $this->environmentPath . '/.env'; + $this->originalEnvironmentFile === null + ? File::delete($path) + : File::put($path, $this->originalEnvironmentFile); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenTheApplicationKeyIsSet(): void + { + config(['app.key' => 'base64:' . str_repeat('a', 44)]); + + $report = $this->app->make(Doctor::class) + ->only('ApplicationKeyIsSet')->run(); + + $outcome = $report->diagnostics()[0]; + + $this->assertSame('pass', $outcome->result->status->value); + $this->assertSame('The application key is configured.', $outcome->result->summary); + } + + public function testReportsAMissingApplicationKey(): void + { + config(['app.key' => '']); + + $report = $this->app->make(Doctor::class) + ->only('ApplicationKeyIsSet')->run(); + + $outcome = $report->diagnostics()[0]; + + $this->assertSame('fail', $outcome->result->status->value); + $this->assertSame('application-key-is-set.missing', $outcome->result->code); + $this->assertSame('The application key is not configured.', $outcome->result->summary); + $this->assertSame('Generate an application key?', $outcome->result->confirmation); + $this->assertSame('Generate an application key with `php artisan key:generate`.', $outcome->result->remediation); + $this->assertSame([EnvironmentMode::Local], $outcome->result->fixableEnvironments); + $this->assertTrue($outcome->fixable()); + $this->assertEquals([Link::docs('encryption')], $outcome->result->links); + } + + public function testDoesNotOfferToGenerateAMissingApplicationKeyInProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + config(['app.key' => '']); + + $outcome = $this->app->make(Doctor::class) + ->only('ApplicationKeyIsSet')->run() + ->diagnostics()[0]; + + $this->assertSame('fail', $outcome->result->status->value); + $this->assertFalse($outcome->fixable()); + $this->assertSame('Generate an application key with `php artisan key:generate`.', $outcome->result->remediation); + } + + #[DataProvider('keyRepairEnvironments')] + public function testGeneratesAnApplicationKeyWhenFixed(bool $cached, bool $explicitEnvironment): void + { + config(['app.key' => '']); + $arguments = $_SERVER['argv']; + $_SERVER['argv'] = ['artisan', 'doctor', ...($explicitEnvironment ? ['--env=doctor'] : [])]; + File::put($this->environmentPath . '/.env', "APP_KEY=\n"); + + if ($explicitEnvironment) { + $this->app->loadEnvironmentFrom('.env.doctor'); + File::put($this->app->environmentFilePath(), "APP_KEY=\n"); + } + + try { + $fix = $this->withEnvironmentValues([ + 'APP_KEY' => null, + ], function () use ($cached): DiagnosticFixOutcome { + if ($cached) { + $this->assertSame(0, Artisan::call('config:cache')); + } + + return $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + new ApplicationKeyIsSet, + DiagnosticResult::fail('The application key is not configured.')->fixable(), + )); + }); + + $this->assertSame('pass', $fix->result->status->value, $fix->result->details ?? ''); + $this->assertSame('application-key-is-set.fix.generated', $fix->result->code); + $this->assertSame('The application key was generated.', $fix->result->summary); + $this->assertStringContainsString(config()->string('app.key'), File::get($this->app->environmentFilePath())); + $this->assertStringStartsWith('base64:', config()->string('app.key')); + + if ($explicitEnvironment) { + $this->assertSame("APP_KEY=\n", File::get($this->environmentPath . '/.env')); + } + + if ($cached) { + $this->assertSame(config('app.key'), (require $this->app->getCachedConfigPath())['app']['key']); + } + } finally { + $_SERVER['argv'] = $arguments; + File::delete($this->app->getCachedConfigPath()); + File::delete($this->environmentPath . '/.env.doctor'); + } + } + + /** + * Provide cached and explicitly selected environment files. + */ + public static function keyRepairEnvironments(): array + { + return [ + 'uncached' => [false, false], + 'cached' => [true, false], + 'explicit environment' => [false, true], + 'cached explicit environment' => [true, true], + ]; + } + + public function testDoesNotPublishAGeneratedKeyThatFreshConfigurationIgnores(): void + { + config(['app.key' => '']); + File::put($this->app->environmentFilePath(), "APP_KEY=\n"); + $configPath = $this->app->configPath('app.php'); + $original = File::get($configPath); + File::put($configPath, " 'external-key'];\n"); + + try { + $fix = $this->withEnvironmentValues([ + 'APP_KEY' => null, + ], fn () => (new ApplicationKeyIsSet)->fix(DiagnosticResult::fail('missing'))); + + $this->assertSame('fail', $fix->status->value); + $this->assertSame('external-key', config('app.key')); + $this->assertStringContainsString('APP_KEY=base64:', File::get($this->app->environmentFilePath())); + $this->assertStringNotContainsString('external-key', $fix->details); + $this->assertStringNotContainsString('base64:', $fix->details); + } finally { + File::put($configPath, $original); + } + } + + public function testAddsAnApplicationKeyWhenTheEnvironmentFileHasNoAppKeyVariable(): void + { + config(['app.key' => '']); + + File::put($this->environmentPath . '/.env', "APP_NAME=Hypervel\n"); + + $fix = $this->withEnvironmentValues([ + 'APP_KEY' => null, + ], fn () => $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + new ApplicationKeyIsSet, + DiagnosticResult::fail('The application key is not configured.')->fixable(), + ))); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame('application-key-is-set.fix.generated', $fix->result->code); + $contents = File::get($this->environmentPath . '/.env'); + $this->assertStringContainsString("APP_NAME=Hypervel\n", $contents); + $this->assertStringContainsString('APP_KEY=', $contents); + $this->assertStringContainsString('base64:', $contents); + } + + public function testReportsWhenTheApplicationKeyCannotBeWrittenToTheEnvironmentFile(): void + { + config(['app.key' => '']); + + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + new ApplicationKeyIsSet, + DiagnosticResult::fail('The application key is not configured.')->fixable(), + )); + + $this->assertSame('fail', $fix->result->status->value); + $this->assertSame('application-key-is-set.fix.generation-failed', $fix->result->code); + $this->assertSame('The application key could not be generated.', $fix->result->summary); + $this->assertStringContainsString('could not be updated', $fix->result->details); + } + + public function testAStaleFixDoesNotRotateAnExistingKey(): void + { + $key = 'base64:' . base64_encode(str_repeat('a', 32)); + config(['app.key' => $key]); + File::put($this->environmentPath . '/.env', 'APP_KEY=' . $key); + + $fix = (new ApplicationKeyIsSet)->fix(DiagnosticResult::fail('missing')); + + $this->assertSame('pass', $fix->status->value); + $this->assertSame($key, config('app.key')); + $this->assertSame('APP_KEY=' . $key, File::get($this->environmentPath . '/.env')); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/BootstrapCacheMatchesEnvironmentTest.php b/tests/Doctor/Feature/Diagnostics/BootstrapCacheMatchesEnvironmentTest.php new file mode 100644 index 0000000000..5176afaaed --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/BootstrapCacheMatchesEnvironmentTest.php @@ -0,0 +1,135 @@ +basePath = ParallelTesting::tempDir('DoctorBootstrapCache'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath . '/storage/framework/views', 0775, true); + $this->app->setBasePath($this->basePath); + $this->app->useStoragePath($this->basePath . '/storage'); + + foreach ([$this->app->getCachedConfigPath(), $this->app->getCachedEventsPath(), $this->app->getCachedRoutesPath()] as $path) { + File::ensureDirectoryExists(dirname($path)); + } + } + + /** + * Remove the bootstrap fixture. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenBootstrapFilesAreNotCachedLocally(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + $result = (new BootstrapCacheMatchesEnvironment)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('The application bootstrap files are not cached.', $result->summary); + } + + public function testWarnsWhenBootstrapFilesAreNotCachedOutsideLocalEnvironments(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + + $result = (new BootstrapCacheMatchesEnvironment)->check(); + + $this->assertSame('warn', $result->status->value); + $this->assertSame('The application bootstrap files are not cached.', $result->summary); + $this->assertEquals([Link::docs('deployment', 'optimization')], $result->links); + } + + public function testNoticesWhenBootstrapFilesAreCachedLocally(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + file_put_contents($this->app->getCachedConfigPath(), 'app->getCachedEventsPath(), 'app->getCachedRoutesPath(), 'basePath . '/storage/framework/views/example.php', 'check(); + + $this->assertSame('notice', $result->status->value); + $this->assertSame('Cached bootstrap files detected: config, events and routes.', $result->summary); + $this->assertNull($result->details); + $this->assertSame('If recent changes are not appearing, run `php artisan optimize:clear`.', $result->remediation); + $this->assertEquals([Link::docs('deployment', 'optimization')], $result->links); + } + + public function testNoticesWhenOneBootstrapFileIsCachedLocally(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + file_put_contents($this->app->getCachedConfigPath(), 'check(); + + $this->assertSame('notice', $result->status->value); + $this->assertSame('Cached bootstrap file detected: config.', $result->summary); + $this->assertNull($result->details); + $this->assertSame('If recent changes are not appearing, run `php artisan optimize:clear`.', $result->remediation); + } + + public function testPassesWhenBootstrapFilesAreCachedOutsideLocalEnvironments(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + + file_put_contents($this->app->getCachedConfigPath(), 'app->getCachedEventsPath(), 'app->getCachedRoutesPath(), 'check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('The application bootstrap files are cached.', $result->summary); + } + + public function testReportsWhichBootstrapFilesAreMissingFromAPartialProductionCache(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + file_put_contents($this->app->getCachedConfigPath(), 'check(); + + $this->assertSame('notice', $result->status->value); + $this->assertSame('Cached bootstrap files: config. Uncached bootstrap files: events and routes.', $result->summary); + } + + public function testCompiledViewsDoNotCountAsCachedBootstrapFiles(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + config(['view.compiled' => $this->basePath . '/storage/framework/views']); + file_put_contents($this->basePath . '/storage/framework/views/example.php', 'check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('The application bootstrap files are not cached.', $result->summary); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php b/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php new file mode 100644 index 0000000000..af5fec3022 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/CacheStoreIsReachableTest.php @@ -0,0 +1,689 @@ +directory = ParallelTesting::tempDir('DoctorCacheStore'); + File::deleteDirectory($this->directory); + File::makeDirectory($this->directory . '/cache', 0775, true); + File::makeDirectory($this->directory . '/database/migrations', 0775, true); + $this->app->useEnvironmentPath($this->app->basePath()); + $path = $this->app->environmentFilePath(); + $this->originalEnvironmentFile = is_file($path) ? File::get($path) : null; + File::delete($path); + $this->app->useDatabasePath($this->directory . '/database'); + } + + /** + * Remove the diagnostic's files. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->directory); + $path = $this->app->basePath('.env'); + $this->originalEnvironmentFile === null + ? File::delete($path) + : File::put($path, $this->originalEnvironmentFile); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenTheDefaultCacheStoreCanBeReached(): void + { + config([ + 'cache.default' => 'array', + 'cache.stores.array' => ['driver' => 'array'], + ]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testLinksToCacheConfigurationWhenTheDefaultStoreCannotBeReached(): void + { + config(['cache.default' => 'missing']); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertEquals([Link::docs('cache', 'configuration')], $result->links); + } + + public function testOffersWorkingAlternativeStoresWhenTheDefaultStoreIsUnreachableLocally(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + $path = $this->directory . '/cache'; + + config(['cache' => [ + 'default' => 'broken', + 'prefix' => '', + 'stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'file' => ['driver' => 'file', 'path' => $path], + ], + ]]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('The application cannot reach the default cache store [broken].', $result->summary); + $this->assertTrue($result->fixable); + $this->assertSame(['file' => 'File'], $result->fixOptions); + $this->assertSame('Keep Broken (repair it manually)', $result->fixDeclineLabel); + $this->assertSame('Which cache store should the application use?', $result->confirmation); + } + + public function testExcludesCandidatesSharingTheFailingDriverEvenUnderACustomStoreName(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache' => [ + 'default' => 'custom', + 'prefix' => '', + 'stores' => [ + 'custom' => ['driver' => 'database', 'table' => 'cache', 'connection' => null], + 'database' => ['driver' => 'database', 'table' => 'cache', 'connection' => null], + ], + ], + ]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertFalse($result->fixable); + $this->assertSame([], $result->fixOptions); + } + + public function testLeavesTheFailureNonFixableWhenNoAlternativeStoresAreViable(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + config(['cache' => [ + 'default' => 'broken', + 'prefix' => '', + 'stores' => [ + 'broken' => ['driver' => 'broken-driver'], + ], + ]]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertFalse($result->fixable); + $this->assertSame([], $result->fixOptions); + $this->assertSame('Check CACHE_STORE and the backing cache service configuration.', $result->remediation); + } + + public function testOffersTheDatabaseStoreWithASetupLabelWhenTheCacheTablesAreMissing(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache' => [ + 'default' => 'broken', + 'prefix' => '', + 'stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'database' => ['driver' => 'database', 'table' => 'cache', 'connection' => null], + ], + ], + ]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertTrue($result->fixable); + $this->assertSame(['database' => 'Database (creates cache tables and runs all pending migrations)'], $result->fixOptions); + } + + #[DataProvider('migrationStates')] + public function testCreatesTheCacheTablesBeforeSwitchingToTheDatabaseStore(bool $alreadyGenerated): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + $basePath = $this->app->environmentPath(); + file_put_contents($basePath . '/.env', "CACHE_STORE=broken\n"); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache' => [ + 'default' => 'broken', + 'prefix' => '', + 'stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'database' => ['driver' => 'database', 'table' => 'cache', 'connection' => null], + ], + ], + ]); + + $diagnostic = new CacheStoreIsReachable; + $result = $diagnostic->check(); + + $this->assertSame(['database' => 'Database (creates cache tables and runs all pending migrations)'], $result->fixOptions); + + if ($alreadyGenerated) { + $this->assertSame(0, Artisan::call('make:cache-table')); + } + + $fix = $this->fixCacheStore($result, 'database'); + + $this->assertSame('pass', $fix->status->value, $fix->details ?? ''); + $this->assertSame('The default cache store was switched to [database].', $fix->summary); + $this->assertTrue(Schema::hasTable('cache')); + $this->assertTrue(Schema::hasTable('cache_locks')); + $this->assertStringContainsString('CACHE_STORE=database', file_get_contents($basePath . '/.env')); + $this->assertSame('database', config('cache.default')); + $this->assertSame('pass', (new CacheStoreIsReachable)->check()->status->value); + } + + /** + * Provide generated and ungenerated migration states. + */ + public static function migrationStates(): array + { + return ['new migrations' => [false], 'pending migrations' => [true]]; + } + + #[DataProvider('persistentSwitchCases')] + public function testSwitchesTheDefaultStoreByWritingTheEnvironmentFile(bool $cached, ?string $externalStore): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + $environmentPath = $this->app->environmentPath(); + $cachePath = $this->directory . '/cache'; + + file_put_contents($environmentPath . '/.env', "CACHE_STORE=broken\n"); + + config(['cache' => [ + 'default' => 'broken', + 'prefix' => '', + 'stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'file' => ['driver' => 'file', 'path' => $cachePath], + ], + ]]); + + $diagnostic = new CacheStoreIsReachable; + + try { + if ($cached) { + $this->assertSame(0, Artisan::call('config:cache')); + } + + $fix = $this->fixCacheStore($diagnostic->check(), 'file', $externalStore); + + $this->assertSame($externalStore === null ? 'pass' : 'fail', $fix->status->value, $fix->details ?? ''); + $this->assertSame('cache-store-is-reachable.fix.' . ($externalStore === null ? 'switched' : 'switch-failed'), $fix->code); + $this->assertStringContainsString('CACHE_STORE=file', file_get_contents($environmentPath . '/.env')); + $this->assertSame($externalStore ?? 'file', config('cache.default')); + + if ($cached) { + $this->assertSame($externalStore ?? 'file', (require $this->app->getCachedConfigPath())['cache']['default']); + } + } finally { + File::delete($this->app->getCachedConfigPath()); + } + } + + /** + * Provide cached and externally overridden store selections. + */ + public static function persistentSwitchCases(): array + { + return [ + 'uncached' => [false, null], + 'cached' => [true, null], + 'external override' => [false, 'array'], + 'cached external override' => [true, 'array'], + ]; + } + + public function testFailsTheSwitchWhenTheEnvironmentFileCannotBeUpdated(): void + { + $environmentPath = $this->directory; + + $this->app->useEnvironmentPath($environmentPath); + + $fix = (new CacheStoreIsReachable)->fix(DiagnosticResult::fail('The application cannot reach the default cache store.'), 'file'); + + $this->assertSame('fail', $fix->status->value); + $this->assertSame('cache-store-is-reachable.fix.switch-failed', $fix->code); + $this->assertStringContainsString($environmentPath, $fix->details); + } + + public function testDoesNotSwitchToAStoreThatFailsItsReProbe(): void + { + $environmentPath = $this->directory; + file_put_contents($environmentPath . '/.env', "CACHE_STORE=broken\n"); + + $this->app->useEnvironmentPath($environmentPath); + + config(['cache.stores.file' => ['driver' => 'broken-driver']]); + + $fix = (new CacheStoreIsReachable)->fix(DiagnosticResult::fail('The application cannot reach the default cache store.'), 'file'); + + $this->assertSame('fail', $fix->status->value); + $this->assertSame('cache-store-is-reachable.fix.switch-failed', $fix->code); + $this->assertStringNotContainsString('CACHE_STORE=file', file_get_contents($environmentPath . '/.env')); + } + + public function testDoesNotProbeOrOfferAlternativeStoresForProductionFailures(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + + $path = $this->directory . '/cache'; + + config(['cache' => [ + 'default' => 'broken', + 'prefix' => '', + 'stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'file' => ['driver' => 'file', 'path' => $path], + ], + ]]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertFalse($result->fixable); + $this->assertSame([], $result->fixOptions); + $this->assertSame('Check CACHE_STORE and the backing cache service configuration.', $result->remediation); + } + + #[DataProvider('probeFailures')] + public function testRejectsFailedRoundTrips(string $mode, string $detail): void + { + config(['cache.default' => 'probe', 'cache.stores.probe' => ['driver' => 'probe']]); + Cache::extend('probe', fn (): Repository => new Repository(new DoctorProbeStore($mode))); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString($detail, $result->details); + } + + /** + * Provide meaningful failures in each part of the round trip. + */ + public static function probeFailures(): array + { + return [ + 'write rejected' => ['false-write', 'did not accept'], + 'wrong value' => ['wrong-read', 'did not return'], + 'cleanup rejected' => ['false-cleanup', 'could not be removed'], + 'cleanup throws' => ['cleanup-throws', 'cleanup failed'], + ]; + } + + #[DataProvider('probeExceptions')] + public function testCleanupPreservesPrimaryFailuresAndCancellation(bool $primaryCanceled, bool $cleanupCanceled): void + { + $primary = $primaryCanceled ? new CanceledException('primary canceled') : new RuntimeException('primary failed'); + $cleanup = $cleanupCanceled ? new CanceledException('cleanup canceled') : new RuntimeException('cleanup failed'); + $store = m::mock(Store::class); + $store->expects('put')->andThrow($primary); + $store->expects('forget')->andThrow($cleanup); + Cache::extend('probe', fn (): Repository => new Repository($store)); + config(['cache.default' => 'probe', 'cache.stores.probe' => ['driver' => 'probe']]); + $caught = null; + $result = null; + + try { + $result = (new CacheStoreIsReachable)->check(); + } catch (Throwable $exception) { + $caught = $exception; + } + + if ($primaryCanceled || $cleanupCanceled) { + $this->assertSame($primaryCanceled ? $primary : $cleanup, $caught); + } else { + $this->assertNull($caught); + $this->assertSame('fail', $result->status->value); + $this->assertSame('primary failed', $result->details); + } + } + + /** + * Provide exception precedence cases. + */ + public static function probeExceptions(): array + { + return [[false, false], [true, false], [false, true], [true, true]]; + } + + public function testNullStoreIsSkippedAndSessionStoreCanRoundTrip(): void + { + config(['cache.default' => 'null']); + $this->assertSame('skip', (new CacheStoreIsReachable)->check()->status->value); + + config(['cache.default' => 'session', 'session.driver' => 'array']); + $this->assertSame('pass', (new CacheStoreIsReachable)->check()->status->value); + } + + #[DataProvider('wrapperConfigurations')] + public function testChecksActualWrapperOperationsAndFailoverCapacity(array $stores, string $expected, bool $unconfirmedCleanup = false): void + { + config(['cache.default' => 'selected', 'cache.stores' => $stores]); + Cache::extend('false-write', fn (): Repository => new Repository(new DoctorProbeStore('false-write'))); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame($expected, $result->status->value, $result->details ?? ''); + + if ($unconfirmedCleanup) { + $this->assertStringContainsString('Cleanup could not be confirmed', $result->details); + } + } + + /** + * Provide supported wrappers and meaningful degraded configurations. + */ + public static function wrapperConfigurations(): array + { + $failover = ['driver' => 'failover', 'stores' => ['first', 'second']]; + $healthy = ['driver' => 'array']; + $unreachable = ['driver' => 'unreachable']; + $falseWrite = ['driver' => 'false-write']; + + return [ + 'healthy failover' => [['selected' => $failover, 'first' => $healthy, 'second' => $healthy], 'pass'], + 'null backup' => [[ + 'selected' => ['driver' => 'failover', 'stores' => ['first', 'null']], + 'first' => $healthy, + ], 'pass'], + 'named null backup' => [['selected' => $failover, 'first' => $healthy, 'second' => ['driver' => 'null']], 'pass'], + 'null primary cannot retain values' => [['selected' => $failover, 'first' => ['driver' => 'null'], 'second' => $healthy], 'fail'], + 'dead primary' => [['selected' => $failover, 'first' => $unreachable, 'second' => $healthy], 'warn', true], + 'dead backup' => [['selected' => $failover, 'first' => $healthy, 'second' => $unreachable], 'warn', true], + 'all unreachable' => [['selected' => $failover, 'first' => $unreachable, 'second' => $unreachable], 'fail'], + 'false writing primary' => [['selected' => $failover, 'first' => $falseWrite, 'second' => $healthy], 'fail'], + 'false writing backup' => [['selected' => $failover, 'first' => $healthy, 'second' => $falseWrite], 'warn'], + 'numeric stack' => [[ + 'selected' => ['driver' => 'stack', 'stores' => ['first', 'second']], + 'first' => $healthy, 'second' => $healthy, + ], 'pass'], + 'named stack' => [[ + 'selected' => ['driver' => 'stack', 'stores' => ['first' => ['ttl' => 20], 'second' => []]], + 'first' => $healthy, 'second' => $healthy, + ], 'pass'], + 'enum stack' => [[ + 'selected' => ['driver' => 'stack', 'stores' => [DoctorCacheStoreName::Zero]], + '0' => $healthy, + ], 'pass'], + 'default stack cycle' => [[ + 'selected' => ['driver' => 'stack', 'stores' => [null]], + ], 'fail'], + 'stack with degraded failover' => [[ + 'selected' => ['driver' => 'stack', 'stores' => ['memory', 'fallback']], + 'memory' => $healthy, 'fallback' => $failover, 'first' => $unreachable, 'second' => $healthy, + ], 'warn', true], + 'failover with stack child' => [[ + 'selected' => $failover, + 'first' => ['driver' => 'stack', 'stores' => ['memory', 'disk']], + 'memory' => $healthy, 'disk' => $healthy, 'second' => $healthy, + ], 'pass'], + 'empty failover' => [['selected' => ['driver' => 'failover', 'stores' => []]], 'fail'], + 'cycle' => [[ + 'selected' => ['driver' => 'stack', 'stores' => ['nested']], + 'nested' => ['driver' => 'failover', 'stores' => ['selected']], + ], 'fail'], + ]; + } + + #[DataProvider('partialTableNames')] + public function testPartiallyMissingTablesRequireManualRepair(string $existingTable): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache.default' => 'broken', + 'cache.stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'database' => ['driver' => 'database', 'table' => 'cache'], + ], + ]); + Schema::create($existingTable, function (Blueprint $table): void { + $table->string('key')->primary(); + $table->text('value'); + $table->integer('expiration'); + }); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame([], $result->fixOptions); + File::put($this->app->environmentFilePath(), "CACHE_STORE=broken\n"); + + $fix = (new CacheStoreIsReachable)->fix($result, 'database'); + + $this->assertSame('fail', $fix->status->value); + $this->assertStringContainsString('both its data and lock tables', $fix->details); + $this->assertSame("CACHE_STORE=broken\n", File::get($this->app->environmentFilePath())); + $this->assertSame([], File::files($this->directory . '/database/migrations')); + } + + /** + * Provide either half of the required database cache schema. + */ + public static function partialTableNames(): array + { + return [['cache'], ['cache_locks']]; + } + + public function testDoesNotSwitchWhenRecordedMigrationsLeaveTablesMissing(): void + { + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache.default' => 'broken', + 'cache.stores.database' => ['driver' => 'database', 'table' => 'cache'], + ]); + File::put($this->app->environmentFilePath(), "CACHE_STORE=broken\n"); + $this->assertSame(0, Artisan::call('make:cache-table')); + $this->assertSame(0, Artisan::call('migrate', ['--force' => true])); + Schema::drop('cache'); + Schema::drop('cache_locks'); + + $diagnostic = new CacheStoreIsReachable; + $fix = $diagnostic->fix($diagnostic->check(), 'database'); + + $this->assertSame('fail', $fix->status->value); + $this->assertStringContainsString('both its data and lock tables', $fix->details); + $this->assertSame('broken', config('cache.default')); + $this->assertSame("CACHE_STORE=broken\n", File::get($this->app->environmentFilePath())); + } + + public function testExistingCustomDatabaseTablesRemainSelectable(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache.default' => 'broken', + 'cache.stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'database' => ['driver' => 'database', 'table' => 'custom_cache', 'lock_table' => 'custom_locks'], + ], + ]); + Schema::create('custom_cache', function (Blueprint $table): void { + $table->string('key')->primary(); + $table->text('value'); + $table->integer('expiration'); + }); + Schema::create('custom_locks', function (Blueprint $table): void { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + + $this->assertSame(['database' => 'Database'], (new CacheStoreIsReachable)->check()->fixOptions); + } + + public function testActiveCacheDiscoveryNormalizesEnumAndDefaultStackEntries(): void + { + config([ + 'cache.default' => 'array', + 'cache.stores.selected' => ['driver' => 'stack', 'stores' => [DoctorCacheStoreName::Zero, null]], + 'cache.stores.0' => ['driver' => 'array'], + ]); + + $this->assertSame(['0', 'array'], ActiveDrivers::cacheStores('selected')); + } + + #[DataProvider('databaseCreationConfigurations')] + public function testOnlyOffersTableCreationForTheGeneratorLayout(array $overrides, bool $eligible): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'cache.default' => 'broken', + 'cache.stores' => [ + 'broken' => ['driver' => 'broken-driver'], + 'database' => [...['driver' => 'database', 'table' => 'cache'], ...$overrides], + ], + ]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame($eligible, array_key_exists('database', $result->fixOptions)); + } + + /** + * Provide supported defaults and layouts the generator cannot create. + */ + public static function databaseCreationConfigurations(): array + { + return [ + 'omitted optional values' => [[], true], + 'null optional values' => [['connection' => null, 'lock_table' => null, 'lock_connection' => null], true], + 'explicit default connection' => [['connection' => 'sqlite', 'lock_connection' => 'sqlite'], true], + 'different data table' => [['table' => 'custom_cache'], false], + 'different lock table' => [['lock_table' => 'custom_locks'], false], + 'different data connection' => [['connection' => 'other'], false], + 'different lock connection' => [['lock_connection' => 'other'], false], + 'missing required table' => [['table' => null], false], + 'different configured driver' => [['driver' => 'unavailable'], false], + ]; + } + + public function testRepeatedFailoverChildrenHaveOneFailureDetail(): void + { + config([ + 'cache.default' => 'selected', + 'cache.stores' => [ + 'selected' => ['driver' => 'failover', 'stores' => ['healthy', 'offline', 'offline']], + 'healthy' => ['driver' => 'array'], + 'offline' => ['driver' => 'unavailable'], + ], + ]); + + $result = (new CacheStoreIsReachable)->check(); + + $this->assertSame('warn', $result->status->value); + $this->assertSame(1, substr_count($result->details, '[offline]:')); + } + + /** + * Repair with a fresh application and an optional external store override. + */ + private function fixCacheStore(DiagnosticResult $result, string $option, ?string $externalStore = null): FixResult + { + return $this->withEnvironmentValues([ + 'CACHE_STORE' => $externalStore, + ], function () use ($result, $option, $externalStore): FixResult { + if ($externalStore !== null) { + $_SERVER['CACHE_STORE'] = $_ENV['CACHE_STORE'] = $externalStore; + } + + return (new CacheStoreIsReachable)->fix($result, $option); + }); + } +} + +enum DoctorCacheStoreName: int +{ + case Zero = 0; +} + +class DoctorProbeStore extends ArrayStore +{ + /** + * Create a store with one failing round-trip operation. + */ + public function __construct(private string $mode) + { + parent::__construct(); + } + + /** + * Store the value unless the write is rejected. + */ + public function put(string $key, mixed $value, int $seconds): bool + { + return $this->mode !== 'false-write' && parent::put($key, $value, $seconds); + } + + /** + * Read the value or simulate a mismatched response. + */ + public function get(string $key): mixed + { + return $this->mode === 'wrong-read' ? 'wrong' : parent::get($key); + } + + /** + * Remove the value unless cleanup fails. + */ + public function forget(string $key): bool + { + return match ($this->mode) { + 'false-cleanup' => false, + 'cleanup-throws' => throw new RuntimeException('cleanup failed'), + default => parent::forget($key), + }; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ComposerAuditPassesTest.php b/tests/Doctor/Feature/Diagnostics/ComposerAuditPassesTest.php new file mode 100644 index 0000000000..0f3ecf69a5 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ComposerAuditPassesTest.php @@ -0,0 +1,108 @@ +basePath = ParallelTesting::tempDir('DoctorComposerAudit'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->setBasePath($this->basePath); + } + + /** + * Remove the Composer fixture. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testReportsComposerAuditAdvisories(): void + { + file_put_contents($this->basePath . '/composer.lock', '{}'); + + Process::fake([ + '*' => Process::result(output: json_encode([ + 'advisories' => [ + 'vendor/package' => [ + ['advisoryId' => 'CVE-0000-0000'], + ], + ], + ], JSON_THROW_ON_ERROR), exitCode: 1), + ]); + + $result = (new ComposerAuditPasses)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('composer-audit-passes.vulnerable', $result->code); + $this->assertStringContainsString('1 security advisory', $result->details); + } + + #[DataProvider('auditReports')] + public function testDistinguishesPolicyFindingsFromExecutionFailures(array|string $report, int $exitCode, string $status, string $code, ?string $details): void + { + file_put_contents($this->basePath . '/composer.lock', '{}'); + Process::fake(['*' => Process::result( + output: is_array($report) ? json_encode($report, JSON_THROW_ON_ERROR) : $report, + exitCode: $exitCode, + )]); + + $result = (new ComposerAuditPasses)->check(); + + $this->assertSame($status, $result->status->value); + $this->assertSame('composer-audit-passes.' . $code, $result->code); + $this->assertSame($details, $result->details); + } + + /** + * Provide completed reports and execution failures. + */ + public static function auditReports(): array + { + return [ + 'abandoned' => [ + ['advisories' => [], 'abandoned' => ['vendor/old' => 'vendor/replacement']], + 1, 'fail', 'policy-failed', '- vendor/old is abandoned. Suggested replacement: vendor/replacement.', + ], + 'filter' => [ + ['advisories' => [], 'filter' => ['vendor/package' => [['listName' => 'malware', 'reason' => 'Compromised release.']]]], + 1, 'fail', 'policy-failed', '- vendor/package matches filter list [malware]. Compromised release.', + ], + 'execution failure' => ['Repository unavailable.', 1, 'fail', 'audit-failed', 'Repository unavailable.'], + 'report only' => [ + ['advisories' => [], 'abandoned' => ['vendor/old' => null]], + 0, 'pass', 'clean', null, + ], + ]; + } + + public function testSkipsWithoutALockFile(): void + { + Process::fake(); + + $this->assertSame('composer-audit-passes.lock-missing', (new ComposerAuditPasses)->check()->code); + Process::assertNothingRan(); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ComposerAutoloadIsValidTest.php b/tests/Doctor/Feature/Diagnostics/ComposerAutoloadIsValidTest.php new file mode 100644 index 0000000000..3d1767e884 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ComposerAutoloadIsValidTest.php @@ -0,0 +1,111 @@ +basePath = ParallelTesting::tempDir('DoctorComposerAutoload'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->setBasePath($this->basePath); + } + + /** + * Remove the Composer fixture. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + #[DataProvider('vendorDirectories')] + public function testChecksComposerAutoloadValidityWithAStrictDryRun(string $directory): void + { + $basePath = $this->basePath; + mkdir($basePath . '/' . $directory); + file_put_contents($basePath . '/composer.json', '{}'); + file_put_contents($basePath . '/' . $directory . '/autoload.php', 'app->make(PackageManifest::class)->vendorPath = $basePath . '/' . $directory; + + Process::fake([ + '*' => Process::result( + output: 'Class App\Foo located in ./app/Bar.php does not comply with psr-4 autoloading standard.', + exitCode: 1, + ), + ]); + + $result = (new ComposerAutoloadIsValid)->check(); + + Process::assertRan(fn (PendingProcess $process): bool => $process->command === [ + 'composer', + 'dump-autoload', + '--dry-run', + '--no-scripts', + '--optimize', + '--strict-psr', + '--strict-ambiguous', + '--no-interaction', + ] && $process->path === $basePath); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('does not comply with psr-4', $result->details); + $this->assertSame('Regenerate Composer autoload files using `composer dump-autoload`?', $result->confirmation); + $this->assertSame('Regenerate Composer autoload files with `composer dump-autoload`.', $result->remediation); + } + + /** + * Provide conventional and relocated vendor directories. + */ + public static function vendorDirectories(): array + { + return [['vendor'], ['dependencies']]; + } + + public function testRegeneratesComposerAutoloadFilesWhenFixed(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', '{}'); + + Process::fake([ + '*' => Process::result(output: 'Generated autoload files'), + ]); + + $fix = (new ComposerAutoloadIsValid)->fix(DiagnosticResult::fail('autoload')); + + Process::assertRan(fn (PendingProcess $process): bool => $process->command === [ + 'composer', + 'dump-autoload', + '--optimize', + '--strict-psr', + '--strict-ambiguous', + '--no-interaction', + ] && $process->path === $basePath && $process->timeout === null); + + $this->assertSame('pass', $fix->status->value); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ComposerLockIsFreshTest.php b/tests/Doctor/Feature/Diagnostics/ComposerLockIsFreshTest.php new file mode 100644 index 0000000000..6756b18d27 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ComposerLockIsFreshTest.php @@ -0,0 +1,201 @@ +basePath = ParallelTesting::tempDir('DoctorComposerLock'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->setBasePath($this->basePath); + } + + /** + * Remove the Composer fixture. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testOffersToGenerateAMissingLockFileLocally(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', '{}'); + + $result = (new ComposerLockIsFresh)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('composer-lock-is-fresh.lock-missing', $result->code); + $this->assertSame('Generate composer.lock and install dependencies using `composer install`?', $result->confirmation); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + $this->assertFalse($result->fixableIn(EnvironmentMode::Production)); + } + + public function testChecksComposerLockFreshnessThroughComposerCheckLockValidation(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', '{}'); + file_put_contents($basePath . '/composer.lock', '{}'); + + Process::fake([ + '*' => Process::result(errorOutput: 'The lock file is not up to date.', exitCode: 1), + ]); + + $result = (new ComposerLockIsFresh)->check(); + + Process::assertRan(fn (PendingProcess $process): bool => $process->command === [ + 'composer', + 'validate', + '--check-lock', + '--no-check-publish', + '--no-check-all', + '--no-interaction', + '--no-ansi', + ]); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('The lock file is not up to date.', $result->details); + $this->assertSame('Run `composer update --lock` to refresh the lock file.', $result->remediation); + $this->assertSame('Refresh composer.lock using `composer update --lock`?', $result->confirmation); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + } + + public function testReportsConstraintMismatchesWithThePackagesThatNeedUpdating(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', '{}'); + file_put_contents($basePath . '/composer.lock', '{}'); + + Process::fake([ + '*' => Process::result(errorOutput: implode(PHP_EOL, [ + './composer.json is valid but your composer.lock has some errors', + '# Lock file errors', + '- The lock file is not up to date with the latest changes in composer.json, it is recommended that you run `composer update` or `composer update `.', + '- Required package "hypervel/passkeys" is in the lock file as "v0.2.1" but that does not satisfy your constraint "^1.27".', + '- Required package "hypervel/wayfinder" is not present in the lock file.', + 'This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.', + 'Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md', + 'and prefer using the "require" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r', + ]), exitCode: 2), + ]); + + $result = (new ComposerLockIsFresh)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('composer-lock-is-fresh.constraint-mismatch', $result->code); + $this->assertSame('composer.lock does not satisfy the package constraints in composer.json.', $result->summary); + $this->assertSame('Run `composer update hypervel/passkeys hypervel/wayfinder` to lock versions that satisfy composer.json.', $result->remediation); + $this->assertSame('Update hypervel/passkeys hypervel/wayfinder using `composer update hypervel/passkeys hypervel/wayfinder`?', $result->confirmation); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + $this->assertSame(['hypervel/passkeys', 'hypervel/wayfinder'], $result->context['packages']); + $this->assertSame(implode(PHP_EOL, [ + '- The lock file is not up to date with the latest changes in composer.json.', + '- Required package "hypervel/passkeys" is in the lock file as "v0.2.1" but that does not satisfy your constraint "^1.27".', + '- Required package "hypervel/wayfinder" is not present in the lock file.', + ]), $result->details); + } + + public function testDoesNotReportUnrelatedComposerValidationErrorsAsStaleLockFiles(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', '{}'); + file_put_contents($basePath . '/composer.lock', '{}'); + + Process::fake([ + '*' => Process::result(errorOutput: 'composer.json is invalid JSON.', exitCode: 2), + ]); + + $result = (new ComposerLockIsFresh)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('composer-lock-is-fresh.inspection-failed', $result->code); + $this->assertSame('Composer could not verify composer.lock freshness.', $result->summary); + $this->assertFalse($result->fixable); + $this->assertSame('composer.json is invalid JSON.', $result->details); + } + + #[DataProvider('fixableOutcomes')] + public function testRunsTheComposerRepairForEachFixableOutcome(Closure $result, array $command): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', '{}'); + + Process::fake([ + '*' => Process::result(output: 'Writing lock file'), + ]); + + $fix = (new ComposerLockIsFresh)->fix($result()); + + Process::assertRan(fn (PendingProcess $process): bool => $process->command === $command + && $process->path === $basePath + && $process->timeout === null); + + $this->assertSame('pass', $fix->status->value); + $this->assertSame('composer-lock-is-fresh.fix.fixed', $fix->code); + $this->assertSame('composer.lock was repaired.', $fix->summary); + } + + /** + * Provide Composer repairs for fixable outcomes. + */ + public static function fixableOutcomes(): array + { + return [ + 'missing lock file' => [ + fn (): DiagnosticResult => DiagnosticResult::fail('missing', 'composer-lock-is-fresh.lock-missing'), + ['composer', 'install', '--no-interaction'], + ], + 'stale lock file' => [ + fn (): DiagnosticResult => DiagnosticResult::fail('stale', 'composer-lock-is-fresh.stale'), + ['composer', 'update', '--lock', '--no-interaction'], + ], + 'constraint mismatch' => [ + fn (): DiagnosticResult => DiagnosticResult::fail('mismatch', 'composer-lock-is-fresh.constraint-mismatch') + ->withContext('packages', ['hypervel/passkeys', 'hypervel/wayfinder']), + ['composer', 'update', 'hypervel/passkeys', 'hypervel/wayfinder', '--no-interaction'], + ], + ]; + } + + public function testReportsWhenComposerCannotRepairTheLockFile(): void + { + Process::fake([ + '*' => Process::result(errorOutput: 'Dependency resolution failed.', exitCode: 2), + ]); + + $fix = (new ComposerLockIsFresh)->fix( + DiagnosticResult::fail('stale', 'composer-lock-is-fresh.stale'), + ); + + $this->assertSame('fail', $fix->status->value); + $this->assertSame('composer-lock-is-fresh.fix.fix-failed', $fix->code); + $this->assertSame('composer.lock could not be repaired.', $fix->summary); + $this->assertStringContainsString('Dependency resolution failed.', $fix->details); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ConfigurationCanBeCachedTest.php b/tests/Doctor/Feature/Diagnostics/ConfigurationCanBeCachedTest.php new file mode 100644 index 0000000000..1a14a75def --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ConfigurationCanBeCachedTest.php @@ -0,0 +1,105 @@ + new stdClass, + 'doctor.unserializable' => fn (): bool => true, + ]); + + $result = (new ConfigurationCanBeCached)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('doctor.unserializable', $result->details); + $this->assertStringNotContainsString('doctor.exportable', $result->details); + $this->assertEquals([Link::docs('configuration', 'configuration-caching')], $result->links); + } + + public function testAcceptsExportableObjectsEnumsAndNestedConfiguration(): void + { + config(['doctor.values' => [ + 'object' => new DoctorExportableConfig('value'), + 'enum' => Status::Pass, + 'nested' => ['value' => true], + ]]); + + $result = (new ConfigurationCanBeCached)->check(); + + $this->assertSame('pass', $result->status->value, $result->details ?? ''); + } + + public function testReportsFailuresFromObjectStateRestoration(): void + { + config(['doctor.values' => new DoctorThrowingConfig]); + + $result = (new ConfigurationCanBeCached)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('Cannot restore this configuration value.', $result->details); + $this->assertStringContainsString('doctor.values', $result->details); + } + + public function testConfigurationRestorationDoesNotSwallowCancellation(): void + { + config(['doctor.values' => new DoctorCanceledConfig]); + + $this->expectException(CanceledException::class); + $this->expectExceptionMessage('Configuration restoration canceled.'); + + (new ConfigurationCanBeCached)->check(); + } +} + +class DoctorExportableConfig +{ + /** + * Create an exportable configuration value. + */ + public function __construct(public string $value) + { + } + + /** + * Restore an exported configuration value. + */ + public static function __set_state(array $properties): self + { + return new self($properties['value']); + } +} + +class DoctorThrowingConfig +{ + /** + * Reject restoration of the exported value. + */ + public static function __set_state(array $properties): self + { + throw new RuntimeException('Cannot restore this configuration value.'); + } +} + +class DoctorCanceledConfig +{ + /** + * Cancel restoration of the exported value. + */ + public static function __set_state(array $properties): self + { + throw new CanceledException('Configuration restoration canceled.'); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ConfigurationFilesCanBeLoadedTest.php b/tests/Doctor/Feature/Diagnostics/ConfigurationFilesCanBeLoadedTest.php new file mode 100644 index 0000000000..f3dfecd364 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ConfigurationFilesCanBeLoadedTest.php @@ -0,0 +1,81 @@ +directory = ParallelTesting::tempDir('DoctorConfigurationFiles'); + File::deleteDirectory($this->directory); + File::makeDirectory($this->directory . '/nested', 0775, true); + $this->app->useConfigPath($this->directory); + } + + /** + * Remove the configuration fixture. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->directory); + } finally { + parent::tearDown(); + } + } + + #[DataProvider('configurationFiles')] + public function testReportsConfigurationFilesThatCannotLoad(string $file): void + { + file_put_contents($this->directory . '/' . $file, "check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame($file . ': broken config', $result->details); + $this->assertEquals([Link::docs('configuration')], $result->links); + } + + /** + * Provide flat and nested configuration files. + */ + public static function configurationFiles(): array + { + return [['broken.php'], ['nested/broken.php']]; + } + + public function testSkipsAnEmptyConfigurationDirectoryAndLoadsNestedFiles(): void + { + $this->assertSame('skip', (new ConfigurationFilesCanBeLoaded)->check()->status->value); + + file_put_contents($this->directory . '/nested/valid.php', ' "value"];'); + + $this->assertSame('pass', (new ConfigurationFilesCanBeLoaded)->check()->status->value); + } + + public function testConfigurationEvaluationDoesNotSwallowCancellation(): void + { + File::put($this->directory . '/nested/canceled.php', 'expectException(CanceledException::class); + $this->expectExceptionMessage('Configuration load canceled.'); + + (new ConfigurationFilesCanBeLoaded)->check(); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/DatabaseConnectionIsReachableTest.php b/tests/Doctor/Feature/Diagnostics/DatabaseConnectionIsReachableTest.php new file mode 100644 index 0000000000..66b578a7e2 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/DatabaseConnectionIsReachableTest.php @@ -0,0 +1,295 @@ + 'testing', + 'database.connections.testing' => $configuration, + 'database.connections.unused' => [ + 'driver' => 'unsupported', + ], + ]); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame('pass', $result->status->value, $result->details ?? ''); + $this->assertSame('database-connection-is-reachable.reachable', $result->code); + $this->assertSame('The application can connect to the default database connection.', $result->summary); + } + + /** + * Provide named and URL-configured SQLite connections. + */ + public static function databaseConfigurations(): array + { + return [ + 'aliased connection' => [['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']], + 'URL configuration' => [['url' => 'sqlite:///:memory:']], + ]; + } + + #[DataProvider('pdoTimeouts')] + public function testAddsABoundedPdoTimeoutToTransientProbes(?int $timeout): void + { + $probe = new stdClass; + $probe->configuration = null; + + config([ + 'database.default' => 'pgsql', + 'database.connections.pgsql' => [ + 'driver' => 'pgsql', + 'host' => '127.0.0.1', + 'port' => '5432', + 'database' => 'hypervel', + 'username' => 'root', + 'password' => '', + 'charset' => 'utf8', + 'prefix' => '', + 'search_path' => 'public', + 'options' => $timeout === null ? [] : [PDO::ATTR_TIMEOUT => $timeout], + ], + ]); + + $this->app->bind('db.connector.pgsql', fn (): ConnectorInterface => new class($probe) implements ConnectorInterface { + /** + * Capture the connector configuration. + */ + public function __construct(private stdClass $probe) + { + } + + /** + * Open a local PDO connection for the probe. + * + * @param array $config + */ + public function connect(array $config): PDO + { + $this->probe->configuration = $config; + + return new PDO('sqlite::memory:'); + } + }); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame('pass', $result->status->value, $result->details ?? ''); + $this->assertIsArray($probe->configuration); + $this->assertArrayNotHasKey('connect_timeout', $probe->configuration); + $this->assertSame($timeout ?? 2, $probe->configuration['options'][PDO::ATTR_TIMEOUT]); + } + + /** + * Provide default and explicit PDO timeouts. + */ + public static function pdoTimeouts(): array + { + return [[null], [7]]; + } + + public function testLinksToDatabaseConfigurationWhenTheConnectionCannotBeReached(): void + { + config([ + 'database.default' => 'pgsql', + 'database.connections.pgsql' => [ + 'driver' => 'pgsql', + 'host' => '127.0.0.1', + 'database' => 'hypervel', + ], + ]); + + $this->app->bind('db.connector.pgsql', fn (): ConnectorInterface => new class implements ConnectorInterface { + /** + * Simulate an unavailable database. + * + * @param array $config + */ + public function connect(array $config): PDO + { + throw new RuntimeException('Database unavailable.'); + } + }); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('Database unavailable.', $result->details); + $this->assertEquals([Link::docs('database', 'configuration')], $result->links); + } + + #[DataProvider('customConnectionOutcomes')] + public function testCustomDriverNeutralConnectionsAreProbedAndDisconnected(bool $fails): void + { + config([ + 'database.default' => 'analytics', + 'database.connections.analytics' => ['driver' => 'custom', 'options' => 'transport-owned'], + ]); + $connection = m::mock(Connection::class); + $version = $connection->expects('getServerVersion'); + + if ($fails) { + $version->andThrow(new RuntimeException('Custom transport unavailable.')); + } else { + $version->andReturn('1.0'); + } + + $connection->expects('disconnect'); + DB::extend('custom', function (array $configuration, string $name) use ($connection): Connection { + $this->assertSame('analytics', $name); + $this->assertSame('custom', $configuration['driver']); + $this->assertSame('transport-owned', $configuration['options']); + + return $connection; + }); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame($fails ? 'fail' : 'pass', $result->status->value); + + if ($fails) { + $this->assertSame('Custom transport unavailable.', $result->details); + } + } + + /** + * Provide successful and failing transport probes. + */ + public static function customConnectionOutcomes(): array + { + return [[false], [true]]; + } + + public function testReportsInvalidPdoOptionsInsteadOfReplacingThem(): void + { + config([ + 'database.default' => 'testing', + 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'options' => false], + ]); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('array_diff_key', $result->details); + } + + #[DataProvider('splitConnections')] + public function testProbesSelectableReadAndWriteRecords(string $suffix, bool $unreachable, array $expectedDatabases): void + { + config([ + 'database.default' => 'testing' . $suffix, + 'database.connections.testing' => [ + 'driver' => 'sqlite', 'database' => 'base', + 'options' => [PDO::ATTR_TIMEOUT => 7], + 'write' => ['database' => 'writer'], + 'read' => [ + ['database' => 'reader-one'], + ['database' => 'reader-two', 'options' => [PDO::ATTR_CASE => PDO::CASE_LOWER]], + ], + ], + ]); + $probe = (object) ['configurations' => []]; + $this->app->bind('db.connector.sqlite', fn (): ConnectorInterface => new class($probe, $unreachable) implements ConnectorInterface { + /** + * Capture selected endpoint configurations. + */ + public function __construct(private stdClass $probe, private bool $unreachable) + { + } + + /** + * Connect each healthy record using a local database. + */ + public function connect(array $config): PDO + { + $this->probe->configurations[$config['database']] = $config; + if ($this->unreachable && $config['database'] === 'reader-two') { + throw new RuntimeException('Replica unavailable.'); + } + + return new PDO('sqlite::memory:'); + } + }); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame($unreachable && $suffix !== '::write' ? 'fail' : 'pass', $result->status->value, $result->details ?? ''); + $this->assertSame($expectedDatabases, array_keys($probe->configurations)); + foreach ($probe->configurations as $database => $configuration) { + $this->assertSame($database === 'reader-two' ? 2 : 7, $configuration['options'][PDO::ATTR_TIMEOUT]); + } + if ($result->status->value === 'fail') { + $this->assertSame('[read record 2] Replica unavailable.', $result->details); + } + } + + /** + * Provide default and explicit role selections with a failing replica. + */ + public static function splitConnections(): array + { + return [ + 'healthy split' => ['', false, ['writer', 'reader-one', 'reader-two']], + 'unreachable replica' => ['', true, ['writer', 'reader-one', 'reader-two']], + 'explicit read' => ['::read', true, ['reader-one', 'reader-two']], + 'explicit write' => ['::write', true, ['writer']], + ]; + } + + #[DataProvider('readExtensionSelections')] + public function testReadRecordExtensionsFollowTheRequestedConnectionRole(string $suffix): void + { + config([ + 'database.default' => 'testing' . $suffix, + 'database.connections.testing' => [ + 'driver' => 'sqlite', 'database' => ':memory:', + 'write' => [], + 'read' => ['driver' => 'replica', 'options' => 'transport-owned'], + ], + ]); + $custom = m::mock(Connection::class); + if ($suffix === '::read') { + $custom->expects('getServerVersion')->andReturn('1.0'); + $custom->expects('disconnect'); + } + DB::extend('replica', function (array $configuration) use ($custom, $suffix): Connection { + $this->assertSame('::read', $suffix); + $this->assertSame('transport-owned', $configuration['options']); + + return $custom; + }); + + $result = (new DatabaseConnectionIsReachable)->check(); + + $this->assertSame($suffix === '::read' ? 'pass' : 'fail', $result->status->value); + if ($suffix === '') { + $this->assertSame('[read record 1] Unsupported driver [replica].', $result->details); + } + } + + /** + * Provide the paths that resolve read-record extensions differently. + */ + public static function readExtensionSelections(): array + { + return [[''], ['::read']]; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/DebugModeMatchesEnvironmentTest.php b/tests/Doctor/Feature/Diagnostics/DebugModeMatchesEnvironmentTest.php new file mode 100644 index 0000000000..36fb967668 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/DebugModeMatchesEnvironmentTest.php @@ -0,0 +1,255 @@ +environmentPath = $this->app->basePath(); + $path = $this->environmentPath . '/.env'; + $this->originalEnvironmentFile = is_file($path) ? File::get($path) : null; + File::delete($path); + $this->app->useEnvironmentPath($this->environmentPath); + } + + /** + * Remove the environment fixture. + */ + protected function tearDown(): void + { + try { + $path = $this->environmentPath . '/.env'; + $this->originalEnvironmentFile === null + ? File::delete($path) + : File::put($path, $this->originalEnvironmentFile); + } finally { + parent::tearDown(); + } + } + + public function testReportsDebugModeInProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + config(['app.debug' => true]); + + $result = (new DebugModeMatchesEnvironment)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('Debug mode is enabled in production.', $result->summary); + $this->assertTrue($result->fixable); + $this->assertSame('Set APP_DEBUG=false in the application environment file?', $result->confirmation); + $this->assertEquals([Link::docs('configuration', 'debug-mode')], $result->links); + } + + public function testTreatsUnmappedEnvironmentsAsProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'preview'); + config(['app.debug' => true]); + + $result = (new DebugModeMatchesEnvironment)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('Debug mode is enabled in production.', $result->summary); + } + + public function testHonorsCustomLocalEnvironmentMappings(): void + { + $this->app->detectEnvironment(fn (): string => 'dev'); + config([ + 'app.debug' => true, + 'doctor.environments.local' => ['local', 'dev'], + ]); + + $result = (new DebugModeMatchesEnvironment)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('Debug mode matches the application environment.', $result->summary); + } + + public function testDisablesDebugModeInTheApplicationEnvironmentFile(): void + { + $environmentPath = $this->environmentPath; + file_put_contents($environmentPath . '/.env', "APP_NAME=Hypervel\nAPP_DEBUG=true\n"); + + $this->app->detectEnvironment(fn (): string => 'production'); + config(['app.debug' => true]); + + $fix = $this->fixDebugMode(); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame('Debug mode was disabled in the application environment file.', $fix->result->summary); + $this->assertStringContainsString('APP_DEBUG=false', file_get_contents($environmentPath . '/.env')); + $this->assertFalse(config('app.debug')); + } + + public function testAddsAppDebugWhenItIsMissingFromTheApplicationEnvironmentFile(): void + { + $environmentPath = $this->environmentPath; + file_put_contents($environmentPath . '/.env', "APP_NAME=Hypervel\n"); + + $this->app->detectEnvironment(fn (): string => 'production'); + config(['app.debug' => true]); + + $fix = $this->fixDebugMode(); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertStringContainsString('APP_DEBUG=false', file_get_contents($environmentPath . '/.env')); + } + + #[DataProvider('persistentRepairCases')] + public function testRepairVerifiesFreshConfiguration(bool $cached, ?string $externalDebug, bool $loadDotenv): void + { + File::put($this->environmentPath . '/.env', "APP_DEBUG=true\n"); + $this->app->detectEnvironment(fn (): string => 'production'); + config(['app.debug' => true]); + + if ($loadDotenv) { + DotenvManager::flushState(); + } + + try { + $this->withEnvironmentValues([ + 'APP_DEBUG' => $externalDebug, + ], function () use ($cached, $externalDebug, $loadDotenv): void { + if ($externalDebug !== null) { + $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = $externalDebug; + } + + try { + if ($loadDotenv) { + DotenvManager::load([$this->environmentPath]); + } + + if ($cached) { + $this->assertSame(0, Artisan::call('config:cache')); + } + + $doctor = $this->app->make(Doctor::class); + $fix = $doctor->fix($doctor->check(new DebugModeMatchesEnvironment)); + + $this->assertSame($externalDebug === null ? 'pass' : 'fail', $fix->result->status->value); + $this->assertSame($externalDebug !== null, config('app.debug')); + $this->assertStringContainsString('APP_DEBUG=false', File::get($this->environmentPath . '/.env')); + + if ($cached) { + $this->assertSame($externalDebug !== null, (require $this->app->getCachedConfigPath())['app']['debug']); + } + } finally { + if ($loadDotenv) { + DotenvManager::flushState(); + } + } + }); + } finally { + File::delete($this->app->getCachedConfigPath()); + } + } + + /** + * Provide cached and externally overridden debug settings. + */ + public static function persistentRepairCases(): array + { + return [ + 'uncached' => [false, null, false], + 'cached' => [true, null, false], + 'external override' => [false, 'true', false], + 'cached external override' => [true, 'true', false], + 'uncached dotenv loaded' => [false, null, true], + 'cache built after dotenv load' => [true, null, true], + 'cache built with external override' => [true, 'true', true], + ]; + } + + public function testRepairDoesNotRecreateACacheRemovedAfterBootstrap(): void + { + File::put($this->environmentPath . '/.env', "APP_DEBUG=true\n"); + + try { + $this->withEnvironmentValue('APP_DEBUG', null, function (): void { + $this->assertSame(0, Artisan::call('config:cache')); + (new LoadConfiguration)->bootstrap($this->app); + $this->assertTrue($this->app->configurationIsCached()); + Artisan::call('config:clear'); + File::put($this->environmentPath . '/.env', "APP_DEBUG=false\n"); + + $failure = EnvironmentConfiguration::refresh('app.debug', false); + + $this->assertNull($failure); + $this->assertFalse(config('app.debug')); + $this->assertFileDoesNotExist($this->app->getCachedConfigPath()); + }); + } finally { + File::delete($this->app->getCachedConfigPath()); + } + } + + public function testVerificationDoesNotSelectAnotherFileUsingAnInheritedAppEnvironment(): void + { + $configPath = $this->app->configPath('app.php'); + $originalConfig = File::get($configPath); + File::put($configPath, " (bool) env('DOCTOR_DEBUG', true)];\n"); + File::put($this->environmentPath . '/.env', "APP_ENV=doctor\nAPP_DEBUG=true\n"); + File::put($this->environmentPath . '/.env.doctor', "DOCTOR_DEBUG=false\n"); + $this->app->detectEnvironment(fn (): string => 'production'); + config(['app.debug' => true]); + DotenvManager::flushState(); + + try { + $this->withEnvironmentValues(['APP_ENV' => null, 'APP_DEBUG' => null, 'DOCTOR_DEBUG' => null], function (): void { + try { + DotenvManager::load([$this->environmentPath]); + + $fix = $this->fixDebugMode(); + + $this->assertSame('fail', $fix->result->status->value); + $this->assertTrue(config('app.debug')); + $this->assertStringContainsString('different value', $fix->result->details); + } finally { + DotenvManager::flushState(); + } + }); + } finally { + File::put($configPath, $originalConfig); + File::delete($this->environmentPath . '/.env.doctor'); + } + } + + /** + * Repair through the diagnostic with an independently bootable application. + */ + private function fixDebugMode(): DiagnosticFixOutcome + { + return $this->withEnvironmentValues([ + 'APP_DEBUG' => null, + ], function (): DiagnosticFixOutcome { + $diagnostic = new DebugModeMatchesEnvironment; + + return $this->app->make(Doctor::class)->fix(new DiagnosticOutcome($diagnostic, $diagnostic->check())); + }); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/EnvironmentFileExistsTest.php b/tests/Doctor/Feature/Diagnostics/EnvironmentFileExistsTest.php new file mode 100644 index 0000000000..69c7d54e80 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/EnvironmentFileExistsTest.php @@ -0,0 +1,190 @@ +detectEnvironment(fn (): string => 'local'); + } + + /** + * Create an isolated, relocated environment directory. + */ + protected function setUp(): void + { + parent::setUp(); + $this->basePath = ParallelTesting::tempDir('DoctorEnvironmentFile'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->useEnvironmentPath($this->basePath); + $this->app->loadEnvironmentFrom('.env'); + } + + /** + * Remove the environment fixture. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenEnvExists(): void + { + $basePath = $this->basePath; + touch($basePath . '/.env'); + + $report = $this->app->make(Doctor::class) + ->only('EnvironmentFileExists')->run(); + + $outcome = $report->diagnostics()[0]; + + $this->assertSame('pass', $outcome->result->status->value); + $this->assertSame("The selected environment file [{$basePath}/.env] exists.", $outcome->result->summary); + } + + public function testPassesWhenTheSelectedEnvironmentSpecificFileExists(): void + { + $basePath = $this->basePath; + touch($basePath . '/.env.testing'); + + $this->app->loadEnvironmentFrom('.env.testing'); + + $report = $this->app->make(Doctor::class) + ->only('EnvironmentFileExists')->run(); + + $this->assertSame('pass', $report->diagnostics()[0]->result->status->value); + } + + public function testReportsAMissingEnvironmentFileWithGuidanceWhenAnExampleExists(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/.env.example', "APP_NAME=Hypervel\n"); + + $report = $this->app->make(Doctor::class) + ->only('EnvironmentFileExists')->run(); + + $outcome = $report->diagnostics()[0]; + + $this->assertSame('fail', $outcome->result->status->value); + $this->assertSame("The selected environment file [{$basePath}/.env] does not exist.", $outcome->result->summary); + $this->assertSame("Copy [{$basePath}/.env.example] to [{$basePath}/.env]?", $outcome->result->confirmation); + $this->assertSame("Copy [{$basePath}/.env.example] to [{$basePath}/.env], then review the copied values.", $outcome->result->remediation); + $this->assertSame([EnvironmentMode::Local], $outcome->result->fixableEnvironments); + $this->assertTrue($outcome->fixable()); + $this->assertEquals([Link::docs('configuration', 'environment-configuration')], $outcome->result->links); + } + + public function testDoesNotOfferToCreateAMissingEnvironmentFileInProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + $basePath = $this->basePath; + file_put_contents($basePath . '/.env.example', "APP_NAME=Hypervel\n"); + + $outcome = $this->app->make(Doctor::class) + ->only('EnvironmentFileExists')->run() + ->diagnostics()[0]; + + $this->assertSame('skip', $outcome->result->status->value); + $this->assertFalse($outcome->fixable()); + $this->assertNull($outcome->result->remediation); + } + + public function testCopiesEnvExampleToEnvWhenFixed(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/.env.example', "APP_NAME=Hypervel\n"); + + $diagnostic = new EnvironmentFileExists; + + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + $diagnostic, + $diagnostic->check(), + )); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame("The environment file [{$basePath}/.env] was created from [{$basePath}/.env.example].", $fix->result->summary); + $this->assertSame("APP_NAME=Hypervel\n", file_get_contents($basePath . '/.env')); + } + + public function testDoesNotOverwriteAnExistingEnvWhenFixed(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/.env.example', "APP_NAME=Example\n"); + + $diagnostic = new EnvironmentFileExists; + $result = $diagnostic->check(); + + file_put_contents($basePath . '/.env', "APP_NAME=Existing\n"); + + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + $diagnostic, + $result, + )); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame("APP_NAME=Existing\n", file_get_contents($basePath . '/.env')); + } + + public function testCreatesAnEmptyEnvWhenEnvExampleIsMissing(): void + { + $basePath = $this->basePath; + + $diagnostic = new EnvironmentFileExists; + $result = $diagnostic->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertTrue($result->fixable); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + $this->assertSame("Create an empty environment file at [{$basePath}/.env]?", $result->confirmation); + $this->assertSame("Create [{$basePath}/.env] with the application's environment variables.", $result->remediation); + $this->assertEquals([Link::docs('configuration', 'environment-configuration')], $result->links); + + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + $diagnostic, + $result, + )); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame("An empty environment file [{$basePath}/.env] was created.", $fix->result->summary); + $this->assertSame('', file_get_contents($basePath . '/.env')); + } + + public function testChecksAndRepairsOnlyTheSelectedFile(): void + { + file_put_contents($this->basePath . '/.env', 'APP_NAME=Unselected'); + file_put_contents($this->basePath . '/.env.other', 'APP_NAME=Other'); + file_put_contents($this->basePath . '/.env.example', 'APP_NAME=Selected'); + $this->app->loadEnvironmentFrom('.env.custom'); + $diagnostic = new EnvironmentFileExists; + $result = $diagnostic->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + $this->assertSame('APP_NAME=Selected', file_get_contents($this->basePath . '/.env.custom')); + $this->assertSame('APP_NAME=Unselected', file_get_contents($this->basePath . '/.env')); + $this->assertSame('APP_NAME=Other', file_get_contents($this->basePath . '/.env.other')); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/EnvironmentFileIsGitIgnoredTest.php b/tests/Doctor/Feature/Diagnostics/EnvironmentFileIsGitIgnoredTest.php new file mode 100644 index 0000000000..541eec2608 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/EnvironmentFileIsGitIgnoredTest.php @@ -0,0 +1,249 @@ +detectEnvironment(fn (): string => 'local'); + } + + /** + * Create an isolated Git repository with a nested application. + */ + protected function setUp(): void + { + parent::setUp(); + $this->repositoryPath = ParallelTesting::tempDir('DoctorEnvironmentGit'); + $this->basePath = $this->repositoryPath . '/application'; + File::deleteDirectory($this->repositoryPath); + File::makeDirectory($this->basePath, 0775, true); + Process::path($this->repositoryPath)->timeout(10)->run(['git', 'init', '--quiet'])->throw(); + $this->app->useEnvironmentPath($this->basePath); + $this->app->loadEnvironmentFrom('.env'); + touch($this->basePath . '/.env'); + } + + /** + * Remove the isolated repository. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->repositoryPath); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenEnvironmentFilesAreGitignored(): void + { + $basePath = $this->basePath; + + file_put_contents($basePath . '/.gitignore', ".env*\n"); + + $result = (new EnvironmentFileIsGitIgnored)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testAddsEnvToAnExistingGitignoreWhenFixed(): void + { + $basePath = $this->basePath; + + file_put_contents($basePath . '/.gitignore', "/vendor\n"); + + $diagnostic = new EnvironmentFileIsGitIgnored; + $result = $diagnostic->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertTrue($result->fixable); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + $this->assertSame("Add an ignore rule for [{$basePath}/.env] to [{$basePath}/.gitignore]?", $result->confirmation); + $this->assertEquals([Link::docs('configuration', 'environment-configuration')], $result->links); + + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome($diagnostic, $result)); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame("The environment file [{$basePath}/.env] is now ignored by Git.", $fix->result->summary); + $this->assertSame("/vendor\n/.env\n", file_get_contents($basePath . '/.gitignore')); + $this->assertSame('pass', $diagnostic->check()->status->value); + } + + public function testCreatesAGitignoreThatIgnoresEnvWhenFixed(): void + { + $basePath = $this->basePath; + + $diagnostic = new EnvironmentFileIsGitIgnored; + $result = $diagnostic->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertTrue($result->fixable); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + $this->assertSame("Create [{$basePath}/.gitignore] to ignore [{$basePath}/.env]?", $result->confirmation); + $this->assertEquals([Link::docs('configuration', 'environment-configuration')], $result->links); + + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome($diagnostic, $result)); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame("/.env\n", file_get_contents($basePath . '/.gitignore')); + $this->assertSame('pass', $diagnostic->check()->status->value); + } + + public function testDoesNotOfferToUpdateGitignoreInProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + $basePath = $this->basePath; + file_put_contents($basePath . '/.gitignore', "/vendor\n"); + + $outcome = $this->app->make(Doctor::class) + ->only('EnvironmentFileIsGitIgnored')->run() + ->diagnostics()[0]; + + $this->assertSame('fail', $outcome->result->status->value); + $this->assertFalse($outcome->fixable()); + $this->assertSame("Add a rule for [{$basePath}/.env] to [{$basePath}/.gitignore] so secrets are not committed.", $outcome->result->remediation); + } + + public function testTrackedFilesFailEvenWithAMatchingIgnoreRule(): void + { + Process::path($this->basePath)->timeout(10)->run(['git', 'add', '--', '.env'])->throw(); + file_put_contents($this->basePath . '/.gitignore', ".env\n"); + $diagnostic = new EnvironmentFileIsGitIgnored; + + $result = $diagnostic->check(); + + $this->assertSame('environment-file-is-git-ignored.tracked', $result->code); + $this->assertFalse($result->fixable); + $this->assertSame('fail', $diagnostic->fix($result)->status->value); + $this->assertSame(".env\n", file_get_contents($this->basePath . '/.gitignore')); + } + + public function testHonorsAncestorIgnoreRulesAndLocalNegation(): void + { + file_put_contents($this->repositoryPath . '/.gitignore', ".env\n"); + $diagnostic = new EnvironmentFileIsGitIgnored; + $this->assertSame('pass', $diagnostic->check()->status->value); + + file_put_contents($this->basePath . '/.gitignore', "!.env\n"); + $result = $diagnostic->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + $this->assertSame("!.env\n/.env\n", file_get_contents($this->basePath . '/.gitignore')); + } + + #[DataProvider('selectedFilenames')] + public function testUsesLiteralSelectedFilenamesForTrackingAndIgnoreRules(string $filename): void + { + file_put_contents($this->basePath . '/.env.other', 'example'); + Process::path($this->basePath)->timeout(10)->run(['git', 'add', '--', '.env.other'])->throw(); + $this->app->loadEnvironmentFrom($filename); + touch($this->basePath . '/' . $filename); + $diagnostic = new EnvironmentFileIsGitIgnored; + + $result = $diagnostic->check(); + + $this->assertTrue($result->fixable, $result->summary); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + $this->assertSame('pass', $diagnostic->check()->status->value); + $this->assertSame('example', file_get_contents($this->basePath . '/.env.other')); + } + + /** + * Provide filenames with Git pattern characters. + */ + public static function selectedFilenames(): array + { + return [['.env.custom'], ['.env*'], ['.env\literal']]; + } + + #[DataProvider('inspectionCommands')] + public function testReportsIndexAndIgnoreInspectionFailures(string $command): void + { + Process::fake(function (PendingProcess $process) use ($command): FakeProcessResult { + if (in_array($command, $process->command, true)) { + return Process::result(errorOutput: 'Git inspection failed', exitCode: 128); + } + + return in_array('rev-parse', $process->command, true) + ? Process::result(output: 'true') + : Process::result(exitCode: 1); + }); + + $result = (new EnvironmentFileIsGitIgnored)->check(); + + $this->assertSame('error', $result->status->value); + $this->assertSame('Git inspection failed', $result->details); + $this->assertFalse($result->fixable); + } + + /** + * Provide the commands that inspect tracking and ignoring. + */ + public static function inspectionCommands(): array + { + return [['ls-files'], ['check-ignore']]; + } + + public function testDoesNotClaimRepairSuccessWhenGitStillReportsTheFileUnprotected(): void + { + Process::fake(fn (PendingProcess $process): FakeProcessResult => in_array('rev-parse', $process->command, true) + ? Process::result(output: 'true') + : Process::result(exitCode: 1)); + $diagnostic = new EnvironmentFileIsGitIgnored; + + $result = $diagnostic->fix($diagnostic->check()); + + $this->assertSame('fail', $result->status->value); + $this->assertSame("/.env\n", file_get_contents($this->basePath . '/.gitignore')); + } + + #[DataProvider('gitFailures')] + public function testDistinguishesMissingGitRepositoriesAndInspectionErrors(int $exitCode, string $output, string $errorOutput, string $status, string $code): void + { + Process::fake(['*' => Process::result(output: $output, errorOutput: $errorOutput, exitCode: $exitCode)]); + + $result = (new EnvironmentFileIsGitIgnored)->check(); + + $this->assertSame($status, $result->status->value); + $this->assertSame('environment-file-is-git-ignored.' . $code, $result->code); + } + + /** + * Provide distinct Git command failure modes. + */ + public static function gitFailures(): array + { + return [ + 'missing executable' => [127, '', 'git: not found', 'skip', 'git-missing'], + 'not repository' => [128, '', 'fatal: not a git repository', 'skip', 'not-repository'], + 'not work tree' => [0, 'false', '', 'skip', 'not-repository'], + 'unsafe ownership' => [128, '', 'fatal: detected dubious ownership in repository', 'error', 'inspection-failed'], + ]; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/FilesystemDisksAreReachableTest.php b/tests/Doctor/Feature/Diagnostics/FilesystemDisksAreReachableTest.php new file mode 100644 index 0000000000..0e88c8de1b --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/FilesystemDisksAreReachableTest.php @@ -0,0 +1,99 @@ +root = ParallelTesting::tempDir('DoctorFilesystemDisk'); + File::deleteDirectory($this->root); + File::makeDirectory($this->root, 0775, true); + } + + /** + * Remove the isolated root. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->root); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenTheDefaultLocalFilesystemDiskIsReachable(): void + { + config([ + 'filesystems.default' => 'doctor', + 'filesystems.disks' => [ + 'doctor' => [ + 'driver' => 'local', + 'root' => $this->root, + ], + 's3' => [ + 'driver' => 's3', + 'key' => 'unused', + 'secret' => 'unused', + 'region' => 'us-east-1', + 'bucket' => 'unused', + ], + ], + ]); + + $result = (new FilesystemDisksAreReachable)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('The application can reach the default filesystem disk.', $result->summary); + } + + public function testSkipsWhenTheDefaultFilesystemDiskIsNotConfigured(): void + { + config([ + 'filesystems.default' => 'missing', + 'filesystems.disks' => [ + 'doctor' => [ + 'driver' => 'local', + 'root' => $this->root, + ], + ], + ]); + + $result = (new FilesystemDisksAreReachable)->check(); + + $this->assertSame('skip', $result->status->value); + $this->assertSame('The default filesystem disk [missing] is not configured.', $result->summary); + $this->assertNull($result->details); + } + + public function testLinksToFilesystemConfigurationWhenTheDefaultDiskCannotBeReached(): void + { + config([ + 'filesystems.default' => 'doctor', + 'filesystems.disks.doctor' => [ + 'driver' => 'local', + 'root' => $this->root . '/missing', + ], + ]); + + $result = (new FilesystemDisksAreReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertEquals([Link::docs('filesystem', 'configuration')], $result->links); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/MigrationsAreUpToDateTest.php b/tests/Doctor/Feature/Diagnostics/MigrationsAreUpToDateTest.php new file mode 100644 index 0000000000..66c923dce0 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/MigrationsAreUpToDateTest.php @@ -0,0 +1,229 @@ +make('config')->set('database.default', 'primary'); + $app->make('config')->set('database.connections', [ + 'primary' => ['driver' => 'sqlite', 'database' => ':memory:', 'migrations_connection' => 'schema'], + 'schema' => ['driver' => 'sqlite', 'database' => ':memory:'], + 'prior' => ['driver' => 'sqlite', 'database' => ':memory:'], + ]); + } + + public function testReportsPendingMigrations(): void + { + $root = ParallelTesting::tempDir('DoctorPendingMigrations'); + File::deleteDirectory($root); + File::makeDirectory($root . '/migrations', 0775, true); + + try { + $this->app->useDatabasePath($root); + touch($root . '/migrations/2024_01_01_000000_create_users_table.php'); + $migrator = $this->app->make('migrator'); + $migrator->usingConnection(null, fn () => $migrator->getRepository()->createRepository()); + + $result = (new MigrationsAreUpToDate)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('2024_01_01_000000_create_users_table', $result->details); + $this->assertSame('Apply pending migrations using `php artisan migrate`?', $result->confirmation); + $this->assertSame('Run `php artisan migrate` to apply the pending migrations.', $result->remediation); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + $this->assertTrue($result->fixableIn(EnvironmentMode::Local)); + $this->assertFalse($result->fixableIn(EnvironmentMode::Production)); + $this->assertEquals([Link::docs('migrations', 'running-migrations')], $result->links); + } finally { + File::deleteDirectory($root); + } + } + + public function testOffersToCreateTheMigrationRepositoryLocally(): void + { + $this->app->instance('migrator', new class { + /** + * Get the available migration files. + */ + public function getMigrationFiles(array $paths): array + { + return ['2024_01_01_000000_create_users_table' => $paths[0] . '/2024_01_01_000000_create_users_table.php']; + } + + /** + * Determine whether the migration repository exists. + */ + public function repositoryExists(): bool + { + return false; + } + + /** + * Get the registered migration paths. + */ + public function paths(): array + { + return []; + } + + /** + * Run the callback on the selected connection. + */ + public function usingConnection(?string $name, callable $callback): mixed + { + return $callback(); + } + }); + + $result = (new MigrationsAreUpToDate)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('Create the migrations table and apply pending migrations using `php artisan migrate`?', $result->confirmation); + $this->assertSame([EnvironmentMode::Local], $result->fixableEnvironments); + } + + public function testAppliesPendingMigrationsWhenFixed(): void + { + $root = ParallelTesting::tempDir('DoctorMigrationRepair'); + File::deleteDirectory($root); + File::makeDirectory($root . '/migrations', 0775, true); + File::makeDirectory($root . '/package', 0775, true); + + try { + $this->app->useDatabasePath($root); + $this->app->make('migrator')->path($root . '/package'); + File::put($root . '/package/2024_01_01_000000_create_doctor_entries_table.php', <<<'PHP' + id(); + }); + } + }; + PHP); + + $diagnostic = new MigrationsAreUpToDate; + $fix = $diagnostic->fix($diagnostic->check()); + + $this->assertSame('pass', $fix->status->value); + $this->assertSame('migrations-are-up-to-date.fix.migrated', $fix->code); + $this->assertSame('Database migrations were applied.', $fix->summary); + $this->assertTrue(DB::connection('schema')->getSchemaBuilder()->hasTable('doctor_entries')); + $this->assertFalse(DB::connection('primary')->getSchemaBuilder()->hasTable('doctor_entries')); + $this->assertSame('pass', $diagnostic->check()->status->value); + } finally { + File::deleteDirectory($root); + } + } + + #[DataProvider('migrationFailures')] + public function testReportsWhenPendingMigrationsCannotBeApplied(?string $exceptionClass): void + { + $kernel = m::mock(Kernel::class); + Artisan::swap($kernel); + $command = $kernel->shouldReceive('call')->once()->with('migrate', ['--no-interaction' => true]); + + if ($exceptionClass !== null) { + $exception = new $exceptionClass('Database connection refused.'); + $command->andThrow($exception); + + if ($exception instanceof CanceledException) { + $this->expectExceptionObject($exception); + } + } else { + $command->andReturn(1); + $kernel->shouldReceive('output')->once()->andReturn('Database connection refused.'); + } + + $fix = (new MigrationsAreUpToDate)->fix(DiagnosticResult::fail('pending')); + + $this->assertSame('fail', $fix->status->value); + $this->assertSame('migrations-are-up-to-date.fix.migration-failed', $fix->code); + $this->assertSame('Database migrations could not be applied.', $fix->summary); + $this->assertStringContainsString('Database connection refused.', $fix->details); + } + + /** + * Provide command failure and cancellation paths. + */ + public static function migrationFailures(): array + { + return [ + 'nonzero exit' => [null], + 'exception' => [RuntimeException::class], + 'cancellation' => [CanceledException::class], + ]; + } + + public function testUsesMigrationConnectionAndRegisteredPathsWithoutChangingAmbientState(): void + { + $root = ParallelTesting::tempDir('DoctorMigrations'); + File::deleteDirectory($root); + File::makeDirectory($root . '/migrations', 0775, true); + File::makeDirectory($root . '/package', 0775, true); + + try { + $this->app->useDatabasePath($root); + touch($root . '/migrations/2024_01_01_000000_application.php'); + touch($root . '/package/2024_01_01_000001_package.php'); + $migrator = $this->app->make('migrator'); + $migrator->path($root . '/package'); + $repository = $migrator->getRepository(); + $repository->setSource('schema'); + $repository->createRepository(); + $repository->log('2024_01_01_000000_application', 1); + $repository->log('2024_01_01_000001_package', 1); + touch($root . '/package/2024_01_01_000003_excluded.php'); + touch($root . '/package/2024_01_01_000004_excluded_by_filename.php'); + Migrator::withoutMigrations([ + '2024_01_01_000003_excluded', + '2024_01_01_000004_excluded_by_filename.php', + ]); + $migrator->setConnection('prior'); + DB::setDefaultConnection('primary'); + + $diagnostic = new MigrationsAreUpToDate; + $this->assertSame('migrations-are-up-to-date.current', $diagnostic->check()->code); + touch($root . '/package/2024_01_01_000002_pending.php'); + $details = $diagnostic->check()->details; + $this->assertStringContainsString('2024_01_01_000002_pending', $details); + $this->assertStringNotContainsString('excluded', $details); + $this->assertSame('primary', DB::getDefaultConnection()); + $this->assertSame('prior', $migrator->getConnection()); + $this->assertSame('prior', $repository->getConnection()->getName()); + } finally { + File::deleteDirectory($root); + } + } +} diff --git a/tests/Doctor/Feature/Diagnostics/PhpVersionSatisfiesComposerRequirementTest.php b/tests/Doctor/Feature/Diagnostics/PhpVersionSatisfiesComposerRequirementTest.php new file mode 100644 index 0000000000..9049414b61 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/PhpVersionSatisfiesComposerRequirementTest.php @@ -0,0 +1,151 @@ +basePath = ParallelTesting::tempDir('DoctorPhpVersion'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath . '/dependencies/composer', 0775, true); + $this->app->setBasePath($this->basePath); + $this->app->make(PackageManifest::class)->vendorPath = $this->basePath . '/dependencies'; + } + + /** + * Remove temporary Composer files. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testReportsWhenPhpDoesNotSatisfyComposerJson(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', json_encode([ + 'require' => [ + 'php' => '<1.0', + ], + ], JSON_THROW_ON_ERROR)); + + $result = $this->app->make(PhpVersionSatisfiesComposerRequirement::class)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame(sprintf('PHP %s does not satisfy the application or installed package requirements.', PHP_VERSION), $result->summary); + $this->assertSame('- root requires php <1.0.', $result->details); + $this->assertEquals([Link::docs('deployment', 'server-requirements')], $result->links); + } + + #[DataProvider('rootRequirements')] + public function testChecksActualPhpWithoutInstalledMetadata(array $composer, string $status): void + { + file_put_contents($this->basePath . '/composer.json', json_encode($composer, JSON_THROW_ON_ERROR)); + + $result = $this->app->make(PhpVersionSatisfiesComposerRequirement::class)->check(); + + $this->assertSame($status, $result->status->value); + } + + /** + * Provide requirements independent of Composer's emulated platform. + */ + public static function rootRequirements(): array + { + return [ + 'no constraints' => [[], 'skip'], + 'compatible' => [['require' => ['php' => '>=8.4']], 'pass'], + 'actual runtime exceeds upper bound' => [[ + 'require' => ['php' => '<8.0'], + 'config' => ['platform' => ['php' => '7.4.0']], + ], 'fail'], + ]; + } + + public function testRechecksInstalledDependencyRequirementsAfterMetadataChanges(): void + { + file_put_contents($this->basePath . '/composer.json', '{"require":{"php":">=8.4"}}'); + $path = $this->basePath . '/dependencies/composer/installed.json'; + file_put_contents($path, json_encode(['packages' => [ + ['name' => 'example/dependency', 'require' => ['php' => '<8.0']], + ]], JSON_THROW_ON_ERROR)); + $diagnostic = $this->app->make(PhpVersionSatisfiesComposerRequirement::class); + + $result = $diagnostic->check(); + $this->assertSame('fail', $result->status->value); + $this->assertSame('- example/dependency requires php <8.0.', $result->details); + + file_put_contents($path, json_encode(['packages' => [ + ['name' => 'example/dependency', 'require' => ['php' => '>=8.4']], + ]], JSON_THROW_ON_ERROR)); + + $this->assertSame('pass', $diagnostic->check()->status->value); + } + + #[DataProvider('phpVersions')] + public function testChecksDistributionAndPrereleaseVersions(string $version, string $constraint, string $status): void + { + file_put_contents($this->basePath . '/composer.json', json_encode([ + 'require' => ['php' => $constraint], + ], JSON_THROW_ON_ERROR)); + + $diagnostic = new class($this->app->make(ComposerJson::class)) extends PhpVersionSatisfiesComposerRequirement { + public string $version; + + /** + * Supply a platform version independently of the test runner's PHP build. + */ + protected function phpVersion(): string + { + return $this->version; + } + }; + $diagnostic->version = $version; + + $result = $diagnostic->check(); + + $this->assertSame($status, $result->status->value); + + if ($status === 'fail') { + $this->assertSame("PHP {$version} does not satisfy the application or installed package requirements.", $result->summary); + $this->assertSame("- root requires php {$constraint}.", $result->details); + } + } + + /** + * Provide distribution suffixes and prereleases with meaningful version constraints. + */ + public static function phpVersions(): array + { + return [ + 'Ubuntu build passes' => ['8.4.12-1ubuntu2.14', '^8.4', 'pass'], + 'Ubuntu build fails upper bound' => ['8.4.12-1ubuntu2.14', '<8.4', 'fail'], + 'Sury build passes' => ['8.4.12-1+ubuntu24.04.1+deb.sury.org+1', '^8.4', 'pass'], + 'Sury build fails upper bound' => ['8.4.12-1+ubuntu24.04.1+deb.sury.org+1', '<8.4', 'fail'], + 'development version is not the stable release' => ['8.4.0-dev', '8.4.0', 'fail'], + 'development version satisfies its own constraint' => ['8.4.0-dev', '8.4.0-dev', 'pass'], + ]; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/PublicStorageLinkExistsTest.php b/tests/Doctor/Feature/Diagnostics/PublicStorageLinkExistsTest.php new file mode 100644 index 0000000000..0f39cf8f8a --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/PublicStorageLinkExistsTest.php @@ -0,0 +1,263 @@ +basePath = ParallelTesting::tempDir('DoctorPublicStorage'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->setBasePath($this->basePath); + } + + /** + * Remove temporary links and files. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testReportsAMissingPublicStorageLinkAndCanFixIt(): void + { + $basePath = $this->basePath; + mkdir($basePath . '/storage/app/public', 0775, true); + mkdir($basePath . '/public', 0775, true); + touch($basePath . '/storage/app/public/avatar.jpg'); + + config(['filesystems.disks.public' => [ + 'driver' => 'local', + 'root' => $basePath . '/storage/app/public', + ], 'filesystems.links' => [$basePath . '/public/storage' => $basePath . '/storage/app/public']]); + + $diagnostic = new PublicStorageLinkExists; + $result = $diagnostic->check(); + $fix = $diagnostic->fix(DiagnosticResult::fail('missing')); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('Create the public storage link using `php artisan storage:link`?', $result->confirmation); + $this->assertSame('Create the public storage link with `php artisan storage:link`.', $result->remediation); + $this->assertEquals([Link::docs('filesystem', 'the-public-disk')], $result->links); + $this->assertSame('pass', $fix->status->value); + $this->assertSame('pass', $diagnostic->check()->status->value); + } + + public function testUsesTheConfiguredPublicPathWhenCheckingTheStorageLink(): void + { + $basePath = $this->basePath; + $publicPath = $basePath . '/web'; + + mkdir($basePath . '/storage/app/public', 0775, true); + mkdir($publicPath, 0775, true); + touch($basePath . '/storage/app/public/avatar.jpg'); + symlink($basePath . '/storage/app/public', $publicPath . '/storage'); + + $this->app->usePublicPath($publicPath); + config(['filesystems.disks.public' => [ + 'driver' => 'local', + 'root' => $basePath . '/storage/app/public', + ], 'filesystems.links' => [$publicPath . '/storage' => $basePath . '/storage/app/public']]); + + $result = (new PublicStorageLinkExists)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('The public storage link exists.', $result->summary); + } + + #[DataProvider('placeholders')] + public function testSkipsWhenPublicStorageOnlyContainsAPlaceholder(string $placeholder): void + { + $basePath = $this->basePath; + mkdir($basePath . '/storage/app/public', 0775, true); + mkdir($basePath . '/public', 0775, true); + touch($basePath . '/storage/app/public/' . $placeholder); + + config(['filesystems.disks.public' => [ + 'driver' => 'local', + 'root' => $basePath . '/storage/app/public', + ], 'filesystems.links' => [$basePath . '/public/storage' => $basePath . '/storage/app/public']]); + + $result = (new PublicStorageLinkExists)->check(); + + $this->assertSame('skip', $result->status->value); + $this->assertSame('The public storage directory does not contain public files.', $result->summary); + } + + /** + * Provide supported directory placeholders. + */ + public static function placeholders(): array + { + return [['.gitignore'], ['.gitkeep']]; + } + + public function testRepairsConfiguredLinksForACustomRootAndAcceptsRelativeLinks(): void + { + $root = $this->basePath . '/assets'; + mkdir($root); + touch($root . '/avatar.jpg'); + $existing = $this->basePath . '/existing'; + $missing = $this->basePath . '/uploads'; + symlink('assets', $existing); + config([ + 'filesystems.disks.public' => ['driver' => 'local', 'root' => $root], + 'filesystems.links' => [$existing => $root, $missing => $root, $this->basePath . '/unrelated' => '/unrelated'], + ]); + $diagnostic = new PublicStorageLinkExists; + $result = $diagnostic->check(); + + $this->assertTrue($result->fixable); + $this->assertStringContainsString($missing, $result->details); + $this->assertStringNotContainsString($existing, $result->details); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + $this->assertSame('pass', $diagnostic->check()->status->value); + $this->assertSame('assets', readlink($existing)); + } + + #[DataProvider('conflicts')] + public function testPreservesConflictingPathsAndDoesNotOfferAMixedRepair(string $kind, string $code): void + { + $root = $this->basePath . '/assets'; + mkdir($root); + touch($root . '/avatar.jpg'); + $link = $this->basePath . '/uploads'; + + match ($kind) { + 'wrong' => symlink($this->basePath, $link), + 'dangling' => symlink($this->basePath . '/missing-target', $link), + 'file' => file_put_contents($link, 'keep'), + 'directory' => mkdir($link), + }; + config([ + 'filesystems.disks.public' => ['driver' => 'local', 'root' => $root], + 'filesystems.links' => [$link => $root, $this->basePath . '/missing' => $root], + ]); + $kernel = m::mock(Kernel::class); + $kernel->shouldNotReceive('call'); + Artisan::swap($kernel); + $diagnostic = new PublicStorageLinkExists; + $result = $diagnostic->check(); + + $this->assertSame('public-storage-link-exists.' . $code, $result->code); + $this->assertFalse($result->fixable); + $this->assertSame('fail', $diagnostic->fix($result)->status->value); + + match ($kind) { + 'wrong' => $this->assertSame($this->basePath, readlink($link)), + 'dangling' => $this->assertSame($this->basePath . '/missing-target', readlink($link)), + 'file' => $this->assertSame('keep', file_get_contents($link)), + 'directory' => $this->assertDirectoryExists($link), + }; + } + + /** + * Provide paths that require an explicit user decision. + */ + public static function conflicts(): array + { + return [ + ['wrong', 'wrong-link'], ['dangling', 'wrong-link'], + ['file', 'conflicting-path'], ['directory', 'conflicting-path'], + ]; + } + + #[DataProvider('commandFailures')] + public function testReportsWhenTheCommandDoesNotRepairTheLink(int $exitCode, ?string $exceptionClass): void + { + $root = $this->basePath . '/assets'; + mkdir($root); + touch($root . '/avatar.jpg'); + config([ + 'filesystems.disks.public' => ['driver' => 'local', 'root' => $root], + 'filesystems.links' => [$this->basePath . '/uploads' => $root], + ]); + $kernel = m::mock(Kernel::class); + Artisan::swap($kernel); + $command = $kernel->shouldReceive('call')->once()->with('storage:link'); + + if ($exceptionClass !== null) { + $exception = new $exceptionClass('The link could not be created.'); + $command->andThrow($exception); + + if ($exception instanceof CanceledException) { + $this->expectExceptionObject($exception); + } + } else { + $command->andReturn($exitCode); + $kernel->shouldReceive('output')->once()->andReturn('The link could not be created.'); + } + + $diagnostic = new PublicStorageLinkExists; + + $result = $diagnostic->fix($diagnostic->check()); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('The link could not be created.', $result->details); + + if ($exceptionClass === null) { + $this->assertStringContainsString($this->basePath . '/uploads', $result->details); + } + } + + /** + * Provide unsuccessful repairs, including a zero exit without a link. + */ + public static function commandFailures(): array + { + return [ + 'zero exit without link' => [0, null], + 'nonzero exit' => [1, null], + 'exception' => [1, RuntimeException::class], + 'cancellation' => [1, CanceledException::class], + ]; + } + + #[DataProvider('disksWithoutLinks')] + public function testSkipsDisksWithoutApplicableLocalLinks(array $disk, string $code): void + { + config(['filesystems.disks.public' => $disk, 'filesystems.links' => []]); + + $result = (new PublicStorageLinkExists)->check(); + + $this->assertSame('skip', $result->status->value); + $this->assertSame('public-storage-link-exists.' . $code, $result->code); + } + + /** + * Provide disks that do not need local links. + */ + public static function disksWithoutLinks(): array + { + return [ + [['driver' => 's3'], 'not-default-local-path'], + [['driver' => 'local', 'root' => '/unused'], 'not-configured'], + ]; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/QueueConnectionIsAsynchronousTest.php b/tests/Doctor/Feature/Diagnostics/QueueConnectionIsAsynchronousTest.php new file mode 100644 index 0000000000..3c8a07c89c --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/QueueConnectionIsAsynchronousTest.php @@ -0,0 +1,171 @@ +app->detectEnvironment(fn (): string => 'local'); + + config([ + 'queue.default' => 'sync', + ]); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('Queued jobs run synchronously.', $result->summary); + } + + public function testWarnsWhenQueuesRunSynchronouslyInProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + config(['queue.default' => 'sync']); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('warn', $result->status->value); + $this->assertSame('Queued jobs run synchronously in production.', $result->summary); + $this->assertEquals([Link::docs('queues', 'connections-vs-queues')], $result->links); + } + + public function testWarnsWhenQueuesRunSynchronouslyInUnmappedEnvironments(): void + { + $this->app->detectEnvironment(fn (): string => 'staging'); + config(['queue.default' => 'sync']); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('warn', $result->status->value); + $this->assertSame('Queued jobs run synchronously in production.', $result->summary); + } + + public function testNoticesWhenQueuedJobsAreProcessedAsynchronouslyLocally(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + config([ + 'queue.default' => 'database', + ]); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('notice', $result->status->value); + $this->assertSame('Queued jobs are processed asynchronously.', $result->summary); + $this->assertSame('If jobs are not being processed, make sure a queue worker is running via `php artisan queue:work`.', $result->remediation); + $this->assertEquals([Link::docs('queues', 'running-the-queue-worker')], $result->links); + } + + public function testPassesWhenQueuedJobsAreProcessedAsynchronouslyOutsideLocalEnvironments(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + config(['queue.default' => 'database']); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('Queued jobs are processed asynchronously.', $result->summary); + } + + public function testSkipsWhenTheDefaultQueueConnectionIsNotConfigured(): void + { + config(['queue.default' => null]); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('skip', $result->status->value); + $this->assertSame('The application does not have a default queue connection configured.', $result->summary); + } + + #[DataProvider('inProcessQueues')] + public function testInProcessQueuesNeedNoExternalWorker(string $driver, string $environment): void + { + $this->app->detectEnvironment(fn (): string => $environment); + config(['queue.default' => 'custom', 'queue.connections.custom' => ['driver' => $driver]]); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('queue-connection-is-asynchronous.in-process', $result->code); + $this->assertNull($result->remediation); + } + + /** + * Provide coroutine queues in both environment modes. + */ + public static function inProcessQueues(): array + { + return [ + ['background', 'local'], ['background', 'production'], + ['deferred', 'local'], ['deferred', 'production'], + ]; + } + + public function testUsesTheFirstConcreteFailoverChildsDriver(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + config([ + 'queue.default' => 'main', + 'queue.connections.main' => ['driver' => 'failover', 'connections' => ['nested', 'database']], + 'queue.connections.nested' => ['driver' => 'failover', 'connections' => ['primary']], + 'queue.connections.primary' => ['driver' => 'sync'], + ]); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('warn', $result->status->value); + $this->assertSame('queue-connection-is-asynchronous.sync-production', $result->code); + } + + public function testCycleBesideAHealthyChildCannotPassDiscovery(): void + { + config([ + 'queue.default' => 'main', + 'queue.connections.main' => ['driver' => 'failover', 'connections' => ['main', 'database']], + ]); + + $result = $this->app->make(Doctor::class)->check(new QueueConnectionIsAsynchronous)->result; + + $this->assertSame('error', $result->status->value); + $this->assertSame('Circular queue connection definition detected: main -> main.', $result->summary); + } + + public function testEmptyFailoverCannotPassAsynchronousCheck(): void + { + config(['queue.default' => 'empty', 'queue.connections.empty' => ['driver' => 'failover', 'connections' => []]]); + + $this->assertSame('fail', (new QueueConnectionIsAsynchronous)->check()->status->value); + } + + #[DataProvider('nullQueues')] + public function testNullQueuesAreReportedAsIntentionallyDisabled(string $connection, array $connections): void + { + config(['queue.default' => $connection, 'queue.connections' => $connections]); + + $result = (new QueueConnectionIsAsynchronous)->check(); + + $this->assertSame('skip', $result->status->value); + $this->assertSame('queue-connection-is-asynchronous.disabled', $result->code); + } + + /** + * Provide the built-in null connection and explicitly configured null drivers. + */ + public static function nullQueues(): array + { + return [ + ['null', []], + ['disabled', ['disabled' => ['driver' => 'null']]], + ['main', ['main' => ['driver' => 'failover', 'connections' => ['null']]]], + ]; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/QueueConnectionIsReachableTest.php b/tests/Doctor/Feature/Diagnostics/QueueConnectionIsReachableTest.php new file mode 100644 index 0000000000..f37067931b --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/QueueConnectionIsReachableTest.php @@ -0,0 +1,145 @@ +make('config')->set('database.default', 'sqlite'); + $app->make('config')->set('database.connections.sqlite', ['driver' => 'sqlite', 'database' => ':memory:']); + } + + public function testReportsAMissingDatabaseQueueTable(): void + { + config([ + 'queue.default' => 'database', + 'queue.connections.database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'doctor_jobs', + ], + ]); + + Schema::dropIfExists('doctor_jobs'); + + $result = (new QueueConnectionIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('doctor_jobs', $result->details); + $this->assertEquals([Link::docs('queues')], $result->links); + } + + public function testDoesNotInventAMissingDatabaseQueueTableSetting(): void + { + config(['queue.default' => 'database', 'queue.connections.database' => ['driver' => 'database']]); + + $result = (new QueueConnectionIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('database queue table is not configured', $result->details); + } + + #[DataProvider('failoverConnections')] + public function testChecksFailoverCapacity(array $children, string $status): void + { + config([ + 'queue.default' => 'main', + 'queue.connections' => [ + 'main' => ['driver' => 'failover', 'connections' => $children], + 'healthy' => ['driver' => 'sync'], + '0' => ['driver' => 'sync'], + 'broken' => ['driver' => 'unsupported'], + 'nested' => ['driver' => 'failover', 'connections' => ['healthy', 'broken', 'broken']], + ], + ]); + + $result = (new QueueConnectionIsReachable)->check(); + + $this->assertSame($status, $result->status->value, $result->details ?? $result->summary); + + if (in_array('broken', $children, true) || in_array('nested', $children, true)) { + $this->assertSame(1, substr_count($result->details, '[broken]')); + } + + if (in_array(null, $children, true) || in_array('', $children, true)) { + $this->assertStringContainsString('Circular queue connection definition', $result->details); + } + } + + /** + * Provide healthy, degraded, failed, and invalid failover graphs. + */ + public static function failoverConnections(): array + { + return [ + 'healthy' => [['healthy'], 'pass'], + 'dead primary' => [['broken', 'healthy'], 'warn'], + 'dead backup' => [['healthy', 'broken'], 'warn'], + 'all unavailable' => [['broken', 'missing'], 'fail'], + 'nested and repeated' => [['nested', 'healthy'], 'warn'], + 'empty' => [[], 'fail'], + 'cycle beside healthy' => [['main', 'healthy'], 'fail'], + 'implicit null child' => [['null'], 'pass'], + 'enum child' => [[IntegerQueueName::Zero], 'pass'], + 'default child cycle' => [[null], 'fail'], + 'empty child selects default' => [[''], 'fail'], + ]; + } + + public function testBuiltInNullConnectionNeedsNoConfigurationRecord(): void + { + config(['queue.default' => 'null', 'queue.connections' => []]); + + $this->assertSame('pass', (new QueueConnectionIsReachable)->check()->status->value); + } + + #[DataProvider('remoteQueues')] + public function testRemoteQueuesMustCompleteAReadOnlyOperation(string $driver, bool $fails): void + { + config(['queue.default' => 'remote', 'queue.connections.remote' => ['driver' => $driver]]); + $queue = m::mock(QueueContract::class); + $probe = $queue->shouldReceive('size')->once()->withNoArgs(); + + if ($fails) { + $probe->andThrow(new RuntimeException('Queue service is unreachable.')); + } else { + $probe->andReturn(0); + } + + Queue::shouldReceive('connection')->with('remote')->andReturn($queue); + + $result = (new QueueConnectionIsReachable)->check(); + + $this->assertSame($fails ? 'fail' : 'pass', $result->status->value); + + if ($fails) { + $this->assertStringContainsString('Queue service is unreachable.', $result->details); + } + } + + /** + * Provide reachable and failing lazy queue transports. + */ + public static function remoteQueues(): array + { + return [['sqs', false], ['sqs', true], ['beanstalkd', false], ['beanstalkd', true]]; + } +} diff --git a/tests/Doctor/Feature/Diagnostics/RecommendedPhpExtensionsAreLoadedTest.php b/tests/Doctor/Feature/Diagnostics/RecommendedPhpExtensionsAreLoadedTest.php new file mode 100644 index 0000000000..c14087819c --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/RecommendedPhpExtensionsAreLoadedTest.php @@ -0,0 +1,51 @@ +shouldReceive('suggestedExtensions')->andReturn(['zend-opcache']); + + $result = (new RecommendedPhpExtensionsAreLoaded($composer))->check(); + + $this->assertSame('recommended-php-extensions-are-loaded.installed', $result->code); + } + + public function testWarnsAboutMissingComposerSuggestedPhpExtensions(): void + { + $basePath = ParallelTesting::tempDir('DoctorRecommendedExtensions'); + File::deleteDirectory($basePath); + File::makeDirectory($basePath, 0775, true); + + try { + file_put_contents($basePath . '/composer.json', json_encode([ + 'suggest' => [ + 'ext-hypervel_doctor_recommended_extension' => 'Used by optional Doctor tests.', + ], + ], JSON_THROW_ON_ERROR)); + + $this->app->setBasePath($basePath); + + $result = $this->app->make(RecommendedPhpExtensionsAreLoaded::class)->check(); + + $this->assertSame('warn', $result->status->value); + $this->assertStringContainsString('ext-hypervel_doctor_recommended_extension', $result->details); + } finally { + File::deleteDirectory($basePath); + } + } +} diff --git a/tests/Doctor/Feature/Diagnostics/RedisConnectionsAreReachableTest.php b/tests/Doctor/Feature/Diagnostics/RedisConnectionsAreReachableTest.php new file mode 100644 index 0000000000..1a32e709f1 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/RedisConnectionsAreReachableTest.php @@ -0,0 +1,163 @@ +make('config')->set(['cache.default' => 'array', 'queue.default' => 'sync', 'session.driver' => 'file']); + } + + public function testPassesWhenConfiguredRedisConnectionsRespond(): void + { + config([ + 'cache.default' => 'redis', + 'cache.stores.redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + 'database.redis' => [ + 'client' => 'phpredis', + 'default' => [], + ], + ]); + + Redis::swap(new class { + /** + * Resolve a responding connection. + */ + public function connection(string $name): object + { + return new class { + /** + * Respond to the reachability probe. + */ + public function ping(): string + { + return 'PONG'; + } + }; + } + }); + + $result = (new RedisConnectionsAreReachable)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testOnlyProbesRedisConnectionsUsedBySelectedServices(): void + { + config([ + 'cache.default' => 'stack', + 'cache.stores.stack' => ['driver' => 'stack', 'stores' => ['array', 'failover' => ['ttl' => 60]]], + 'cache.stores.failover' => ['driver' => 'failover', 'stores' => ['redis', 'redis']], + 'cache.stores.redis' => [ + 'driver' => 'redis', + 'connection' => 'cache', + 'lock_connection' => 'locks', + ], + 'cache.stores.unused' => ['driver' => 'redis', 'connection' => 'unused'], + 'queue.default' => 'failover', + 'queue.connections.failover' => ['driver' => 'failover', 'connections' => ['redis', 'sync']], + 'queue.connections.redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + 'session.driver' => 'redis', + 'session.connection' => null, + 'database.redis' => [ + 'client' => 'phpredis', + 'default' => [], + 'cache' => [], + 'locks' => [], + 'session' => [], + 'unused' => [], + ], + ]); + + $redis = new class { + public array $connections = []; + + /** + * Record the active connections and provide a responding probe. + */ + public function connection(string $name): object + { + $this->connections[] = $name; + + return new class { + /** + * Respond to the reachability probe. + */ + public function ping(): string + { + return 'PONG'; + } + }; + } + }; + + Redis::swap($redis); + + $result = (new RedisConnectionsAreReachable)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame(['cache', 'locks', 'default', 'session'], $redis->connections); + } + + public function testSkipsWhenRedisIsOnlyScaffoldedAndUnused(): void + { + config([ + 'cache.default' => 'array', + 'queue.default' => 'sync', + 'session.driver' => 'file', + 'database.redis' => [ + 'client' => 'phpredis', + 'default' => [], + ], + ]); + + $result = (new RedisConnectionsAreReachable)->check(); + + $this->assertSame('skip', $result->status->value); + } + + public function testLinksToRedisConfigurationWhenAnActiveConnectionCannotBeReached(): void + { + config([ + 'cache.default' => 'redis', + 'cache.stores.redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + ]); + + Redis::swap(new class { + /** + * Simulate an unreachable Redis connection. + */ + public function connection(string $name): object + { + throw new RuntimeException("Redis connection [{$name}] is unavailable."); + } + }); + + $result = (new RedisConnectionsAreReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertEquals([Link::docs('redis', 'configuration')], $result->links); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/RedisSupportsConfiguredFeaturesTest.php b/tests/Doctor/Feature/Diagnostics/RedisSupportsConfiguredFeaturesTest.php new file mode 100644 index 0000000000..9194eabd11 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/RedisSupportsConfiguredFeaturesTest.php @@ -0,0 +1,158 @@ +make('config')->set([ + 'cache.default' => 'array', + 'queue.default' => 'sync', + 'session.driver' => 'file', + 'session.track_user_sessions' => false, + ]); + } + + public function testInactiveRedisFeaturesDoNotRequireAConnection(): void + { + config(['cache.stores.unused' => ['driver' => 'redis', 'tag_mode' => 'any', 'connection' => 'unavailable']]); + $this->redisInfo([]); + + $this->assertSame('skip', (new RedisSupportsConfiguredFeatures)->check()->status->value); + } + + #[RequiresPhpExtension('redis', '>=' . RedisRequirements::MINIMUM_PHPREDIS_VERSION)] + public function testOrdinaryRedisOnlyRequiresTheBaseClientWithoutServerInfo(): void + { + config([ + 'cache.default' => 'ordinary', + 'cache.stores.ordinary' => ['driver' => 'redis', 'connection' => 'unavailable', 'lock_connection' => 'locks'], + ]); + $this->redisInfo([]); + + $this->assertSame('pass', (new RedisSupportsConfiguredFeatures)->check()->status->value); + } + + #[RequiresPhpExtension('redis', '>=' . RedisRequirements::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION)] + #[DataProvider('activeFeatures')] + public function testOnlyChecksServersForActiveHashFieldFeatures(TagMode|string $mode, bool $trackSessions, array $servers, string $status): void + { + config([ + 'cache.default' => 'stack', + 'cache.stores.stack' => ['driver' => 'stack', 'stores' => ['array', 'feature-cache']], + 'cache.stores.feature-cache' => ['driver' => 'redis', 'connection' => 'data', 'lock_connection' => 'locks', 'tag_mode' => $mode], + 'session.driver' => 'redis', + 'session.connection' => 'data', + 'session.track_user_sessions' => $trackSessions, + ]); + $this->redisInfo($servers); + + $result = (new RedisSupportsConfiguredFeatures)->check(); + + $this->assertSame($status, $result->status->value, $result->details ?? $result->summary); + + if ($status === 'fail') { + $this->assertStringContainsString('data', $result->details); + $this->assertStringContainsString('Hash field expiration requires', $result->details); + } + } + + /** + * Provide feature selections with supported and unsupported servers. + */ + public static function activeFeatures(): array + { + return [ + 'any-tag cache' => ['any', false, ['data' => ['redis_version' => '7.4.0']], 'fail'], + 'enum any-tag cache' => [TagMode::Any, false, ['data' => ['redis_version' => '8.0.0']], 'pass'], + 'session tracking' => ['all', true, ['data' => ['redis_version' => '7.4.0']], 'fail'], + 'shared feature connection on Valkey' => ['any', true, ['data' => ['valkey_version' => '9.0.0', 'redis_version' => '7.2.0']], 'pass'], + 'no hash-field features' => ['all', false, [], 'pass'], + ]; + } + + #[RequiresPhpExtension('redis', '<' . RedisRequirements::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION)] + public function testUnsupportedClientFailsBeforeReadingServerInfo(): void + { + config([ + 'cache.default' => 'features', + 'cache.stores.features' => ['driver' => 'redis', 'tag_mode' => 'any', 'connection' => 'unavailable'], + ]); + $this->redisInfo([]); + + $result = (new RedisSupportsConfiguredFeatures)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('phpredis >=', $result->details); + } + + #[RequiresPhpExtension('redis', '>=' . RedisRequirements::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION)] + public function testUnknownServerVersionCannotEstablishFeatureSupport(): void + { + config(['session.driver' => 'redis', 'session.connection' => null, 'session.track_user_sessions' => true]); + $this->redisInfo(['session' => []]); + + $result = (new RedisSupportsConfiguredFeatures)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('Could not determine Redis/Valkey version', $result->details); + } + + /** + * Provide server information only for connections that may require it. + */ + private function redisInfo(array $servers): void + { + Redis::swap(new class($servers) { + /** + * Capture available server metadata. + */ + public function __construct(private array $servers) + { + } + + /** + * Resolve a simulated server without accepting inactive connections. + */ + public function connection(string $name): object + { + if (! array_key_exists($name, $this->servers)) { + throw new RuntimeException('Unexpected server inspection: ' . $name); + } + + return new class($this->servers[$name]) { + /** + * Capture one server's INFO response. + */ + public function __construct(private array $response) + { + } + + /** + * Return the requested server metadata. + */ + public function info(string $section): array + { + return $this->response; + } + }; + } + }); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/RequiredConfigurationValuesAreSetTest.php b/tests/Doctor/Feature/Diagnostics/RequiredConfigurationValuesAreSetTest.php new file mode 100644 index 0000000000..d0a616af9a --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/RequiredConfigurationValuesAreSetTest.php @@ -0,0 +1,462 @@ +check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testReportsMissingCredentialsForTheActivePusherBroadcastConnection(): void + { + config([ + 'broadcasting.default' => 'pusher', + 'broadcasting.connections.pusher.driver' => 'pusher', + 'broadcasting.connections.pusher.app_id' => null, + 'broadcasting.connections.pusher.key' => '', + 'broadcasting.connections.pusher.secret' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('broadcasting.connections.pusher.app_id', $result->details); + $this->assertStringContainsString('broadcasting.connections.pusher.key', $result->details); + $this->assertStringContainsString('broadcasting.connections.pusher.secret', $result->details); + $this->assertEquals([Link::docs('configuration', 'environment-configuration')], $result->links); + } + + public function testPassesWhenTheActiveBroadcastConnectionIsFullyConfigured(): void + { + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + 'broadcasting.connections.reverb.app_id' => '12345', + 'broadcasting.connections.reverb.key' => 'key', + 'broadcasting.connections.reverb.secret' => 'secret', + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testIgnoresCredentialsForInactiveBroadcastConnections(): void + { + config([ + 'broadcasting.default' => 'null', + 'broadcasting.connections.null.driver' => 'null', + 'broadcasting.connections.pusher.driver' => 'pusher', + 'broadcasting.connections.pusher.key' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testResolvesRenamedBroadcastConnectionsByDriver(): void + { + config([ + 'broadcasting.default' => 'websockets', + 'broadcasting.connections.websockets.driver' => 'pusher', + 'broadcasting.connections.websockets.app_id' => null, + 'broadcasting.connections.websockets.key' => null, + 'broadcasting.connections.websockets.secret' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('broadcasting.connections.websockets.app_id', $result->details); + $this->assertStringContainsString('broadcasting.connections.websockets.key', $result->details); + $this->assertStringContainsString('broadcasting.connections.websockets.secret', $result->details); + } + + public function testRequiresTheAblyKeyForAblyBroadcastConnections(): void + { + config([ + 'broadcasting.default' => 'ably', + 'broadcasting.connections.ably.driver' => 'ably', + 'broadcasting.connections.ably.key' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('broadcasting.connections.ably.key', $result->details); + $this->assertStringNotContainsString('secret', $result->details); + } + + public function testRequiresABucketAndRegionForTheDefaultS3FilesystemDisk(): void + { + config([ + 'filesystems.default' => 'media', + 'filesystems.disks.media.driver' => 's3', + 'filesystems.disks.media.bucket' => null, + 'filesystems.disks.media.region' => null, + 'filesystems.disks.media.key' => null, + 'filesystems.disks.media.secret' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('filesystems.disks.media.bucket', $result->details); + $this->assertStringContainsString('filesystems.disks.media.region', $result->details); + $this->assertStringNotContainsString('filesystems.disks.media.key', $result->details); + $this->assertStringNotContainsString('filesystems.disks.media.secret', $result->details); + } + + public function testIgnoresS3ValuesWhenTheDefaultDiskIsLocal(): void + { + config([ + 'filesystems.default' => 'local', + 'filesystems.disks.local.driver' => 'local', + 'filesystems.disks.s3.driver' => 's3', + 'filesystems.disks.s3.bucket' => null, + 'filesystems.disks.s3.region' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + #[DataProvider('loggingStacks')] + public function testRequiresTheWebhookUrlForActiveSlackLogChannels(array|string $channels): void + { + config([ + 'logging.default' => 'stack', + 'logging.channels.stack.driver' => 'stack', + 'logging.channels.stack.channels' => $channels, + 'logging.channels.single.driver' => 'single', + 'logging.channels.alerts.driver' => 'slack', + 'logging.channels.alerts.url' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('logging.channels.alerts.url', $result->details); + } + + /** + * Provide the channel forms accepted by the logging stack driver. + */ + public static function loggingStacks(): array + { + return [ + 'array' => [['single', 'alerts']], + 'comma separated' => ['single, alerts '], + 'enum' => [['single', DoctorLogChannel::Alerts]], + ]; + } + + public function testIgnoresInactiveLogChannels(): void + { + config([ + 'logging.default' => 'single', + 'logging.channels.single.driver' => 'single', + 'logging.channels.slack.driver' => 'slack', + 'logging.channels.slack.url' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testRequiresAHostAndPortForSyslogUdpLogChannels(): void + { + config([ + 'logging.default' => 'papertrail', + 'logging.channels.papertrail.driver' => 'monolog', + 'logging.channels.papertrail.handler' => SyslogUdpHandler::class, + 'logging.channels.papertrail.handler_with.host' => null, + 'logging.channels.papertrail.handler_with.port' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('logging.channels.papertrail.handler_with.host', $result->details); + $this->assertStringContainsString('logging.channels.papertrail.handler_with.port', $result->details); + } + + public function testRequiresPostmarkCredentialsForTheActiveMailer(): void + { + config([ + 'mail.default' => 'postmark', + 'mail.mailers.postmark.transport' => 'postmark', + 'mail.mailers.postmark.token' => null, + 'mail.mailers.postmark.key' => null, + 'services.postmark.token' => null, + 'services.postmark.key' => null, + 'services.resend.key' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('services.postmark.key', $result->details); + $this->assertStringNotContainsString('services.resend.key', $result->details); + } + + #[DataProvider('postmarkCredentials')] + public function testRecognizesSupportedPostmarkCredentialLocations(string $credential, string $status): void + { + config([ + 'mail.default' => 'transactional', + 'mail.mailers.transactional.transport' => 'postmark', + 'mail.mailers.transactional.token' => null, + 'mail.mailers.transactional.key' => null, + 'services.postmark.token' => null, + 'services.postmark.key' => null, + $credential => 'postmark-credential', + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame($status, $result->status->value); + } + + /** + * Provide supported keys and unsupported legacy token locations. + */ + public static function postmarkCredentials(): array + { + return [ + 'mailer token' => ['mail.mailers.transactional.token', 'fail'], + 'mailer key' => ['mail.mailers.transactional.key', 'pass'], + 'service token' => ['services.postmark.token', 'fail'], + 'service key' => ['services.postmark.key', 'pass'], + ]; + } + + public function testRespectsHypervelPostmarkCredentialPrecedence(): void + { + config([ + 'mail.default' => 'postmark', + 'mail.mailers.postmark.transport' => 'postmark', + 'mail.mailers.postmark.key' => '', + 'services.postmark.key' => 'fallback-key', + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('mail.mailers.postmark.key', $result->details); + } + + public function testResolvesMailersBehindFailoverTransports(): void + { + config([ + 'mail.default' => 'failover', + 'mail.mailers.failover.transport' => 'failover', + 'mail.mailers.failover.mailers' => ['transactional', 'log'], + 'mail.mailers.transactional.transport' => 'resend', + 'mail.mailers.log.transport' => 'log', + 'services.resend.key' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('services.resend.key', $result->details); + } + + public function testIgnoresCredentialsForUnusedMailers(): void + { + config([ + 'mail.default' => 'log', + 'mail.mailers.log.transport' => 'log', + 'mail.mailers.postmark.transport' => 'postmark', + 'services.postmark.token' => null, + 'services.resend.key' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testRequiresAHostForTheActiveSmtpMailer(): void + { + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp.transport' => 'smtp', + 'mail.mailers.smtp.host' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('mail.mailers.smtp.host', $result->details); + } + + public function testUsesTheDefaultCacheStoreWhenSqsOverflowStoreIsNull(): void + { + config([ + 'queue.default' => 'jobs', + 'queue.connections.jobs.driver' => 'sqs', + 'queue.connections.jobs.overflow.enabled' => true, + 'queue.connections.jobs.overflow.store' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + public function testIgnoresTheOverflowStoreWhenSqsQueueOverflowIsDisabled(): void + { + config([ + 'queue.default' => 'jobs', + 'queue.connections.jobs.driver' => 'sqs', + 'queue.connections.jobs.overflow.enabled' => false, + 'queue.connections.jobs.overflow.store' => null, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('pass', $result->status->value); + } + + #[DataProvider('mercureCredentials')] + public function testChecksEffectiveMercurePublishingAndSubscriptionCredentials(array $credentials, string $status, ?string $missing): void + { + config([ + 'broadcasting.default' => 'notifications', + 'broadcasting.connections.notifications' => ['driver' => 'mercure', 'url' => 'https://hub.example.test/.well-known/mercure', ...$credentials], + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame($status, $result->status->value); + + if ($missing !== null) { + $this->assertStringContainsString('broadcasting.connections.notifications.' . $missing, $result->details); + } + } + + /** + * Provide effective Mercure credentials and missing values. + */ + public static function mercureCredentials(): array + { + return [ + 'shared secret' => [['secret' => 'shared'], 'pass', null], + 'separate secrets' => [['publish_secret' => 'publisher', 'subscribe_secret' => 'subscriber'], 'pass', null], + 'empty override falls back' => [['secret' => 'shared', 'publish_secret' => '', 'subscribe_secret' => null], 'pass', null], + 'no subscription secret' => [['publish_secret' => 'publisher'], 'fail', 'secret'], + 'no publishing secret' => [['subscribe_secret' => 'subscriber'], 'fail', 'secret'], + 'whitespace override is selected' => [['secret' => 'shared', 'publish_secret' => ' '], 'fail', 'publish_secret'], + 'missing URL' => [['secret' => 'shared', 'url' => null], 'fail', 'url'], + ]; + } + + public function testFindsRequiredSettingsBehindQueueFailover(): void + { + config([ + 'queue.default' => 'main', + 'queue.connections.main' => ['driver' => 'failover', 'connections' => ['sync', 'overflow']], + 'queue.connections.overflow' => ['driver' => 'sqs', 'overflow' => ['enabled' => true, 'store' => '']], + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('queue.connections.overflow.overflow.store', $result->details); + } + + #[DataProvider('effectiveMailerConfigurations')] + public function testChecksTheMailFactorysEffectiveConfiguration(array $mailer, array $services, string $status, ?string $missing): void + { + config([ + 'mail.default' => 'selected', + 'mail.mailers.selected' => $mailer, + 'services' => $services, + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame($status, $result->status->value, $result->details ?? ''); + + if ($missing !== null) { + $this->assertStringContainsString($missing, $result->details); + } + } + + /** + * Provide mail URLs and transport-specific credential precedence. + */ + public static function effectiveMailerConfigurations(): array + { + return [ + 'SMTP URL supplies transport and host' => [['url' => 'smtp://mail.example.test:587'], [], 'pass', null], + 'URL overrides wrapper transport' => [['transport' => 'failover', 'url' => 'smtp://mail.example.test', 'mailers' => ['missing']], [], 'pass', null], + 'undefined failover child preserves other missing credentials' => [['transport' => 'failover', 'mailers' => ['missing', 'postmark']], [], 'fail', 'services.postmark.key'], + 'URL query overrides an existing host' => [['transport' => 'smtp', 'host' => 'original', 'url' => 'smtp://mail.example.test?host='], [], 'fail', 'mail.mailers.selected.host'], + 'Resend mailer key' => [['transport' => 'resend', 'key' => 'key'], [], 'pass', null], + 'Resend null falls back' => [['transport' => 'resend', 'key' => null], ['resend' => ['key' => 'service-key']], 'pass', null], + 'Resend empty override' => [['transport' => 'resend', 'key' => ''], ['resend' => ['key' => 'service-key']], 'fail', 'mail.mailers.selected.key'], + 'Cloudflare mixed sources' => [['transport' => 'cloudflare', 'account_id' => 'account'], ['cloudflare' => ['key' => 'service-key']], 'pass', null], + 'Cloudflare legacy token' => [['transport' => 'cloudflare', 'account_id' => 'account', 'token' => 'legacy'], [], 'fail', 'services.cloudflare.key'], + 'Mailgun service record' => [['transport' => 'mailgun', 'secret' => null, 'domain' => 'ignored'], ['mailgun' => ['secret' => 'secret', 'domain' => 'mail.example.test']], 'pass', null], + 'Mailgun selected mailer record' => [['transport' => 'mailgun', 'secret' => 'secret', 'domain' => 'mail.example.test'], [], 'pass', null], + 'Mailgun does not merge records' => [['transport' => 'mailgun', 'secret' => 'secret'], ['mailgun' => ['domain' => 'ignored']], 'fail', 'mail.mailers.selected.domain'], + ]; + } + + #[DataProvider('syslogArguments')] + public function testUsesTheMergedSyslogHandlerArguments(?array $handlerWith, string $status): void + { + config([ + 'logging.default' => 'papertrail', + 'logging.channels.papertrail' => [ + 'driver' => 'monolog', + 'handler' => SyslogUdpHandler::class, + 'with' => ['host' => 'logs.example.test', 'port' => 514], + 'handler_with' => $handlerWith, + ], + ]); + + $result = (new RequiredConfigurationValuesAreSet)->check(); + + $this->assertSame($status, $result->status->value); + + if ($status === 'fail') { + $this->assertStringContainsString('logging.channels.papertrail.handler_with.host', $result->details); + } + } + + /** + * Provide merged and overridden handler arguments. + */ + public static function syslogArguments(): array + { + return [ + 'with only' => [null, 'pass'], + 'partial override' => [['port' => 1514], 'pass'], + 'null field overrides with' => [['host' => null], 'fail'], + ]; + } +} + +enum DoctorLogChannel: string +{ + case Alerts = 'alerts'; +} diff --git a/tests/Doctor/Feature/Diagnostics/RequiredPhpExtensionsAreLoadedTest.php b/tests/Doctor/Feature/Diagnostics/RequiredPhpExtensionsAreLoadedTest.php new file mode 100644 index 0000000000..2aff6d3ca9 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/RequiredPhpExtensionsAreLoadedTest.php @@ -0,0 +1,146 @@ +basePath = ParallelTesting::tempDir('DoctorRequiredExtensions'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath . '/dependencies/composer', 0775, true); + $this->app->setBasePath($this->basePath); + $this->app->make(PackageManifest::class)->vendorPath = $this->basePath . '/dependencies'; + } + + /** + * Remove temporary Composer files. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testReportsMissingComposerRequiredPhpExtensions(): void + { + $basePath = $this->basePath; + file_put_contents($basePath . '/composer.json', json_encode([ + 'require' => [ + 'ext-hypervel_doctor_missing_extension' => '*', + ], + ], JSON_THROW_ON_ERROR)); + + $result = $this->app->make(RequiredPhpExtensionsAreLoaded::class)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('ext-hypervel_doctor_missing_extension', $result->details); + } + + #[DataProvider('runtimeConstraints')] + public function testChecksTheLoadedExtensionVersion(string $constraint, string $status): void + { + file_put_contents($this->basePath . '/composer.json', json_encode([ + 'name' => 'example/app', + 'require' => ['ext-swoole' => $constraint], + ], JSON_THROW_ON_ERROR)); + + $result = $this->app->make(RequiredPhpExtensionsAreLoaded::class)->check(); + + $this->assertSame($status, $result->status->value); + + if ($status === 'fail') { + $this->assertStringContainsString('loaded ' . phpversion('swoole'), $result->details); + $this->assertStringContainsString('example/app requires ' . $constraint, $result->details); + } + } + + /** + * Provide compatible and incompatible runtime constraints. + */ + public static function runtimeConstraints(): array + { + return [['>=0', 'pass'], ['<0', 'fail']]; + } + + #[DataProvider('extensionProviders')] + public function testInstalledProvidersCanSatisfyDependencyExtensionRequirements(array $provider, string $status): void + { + file_put_contents($this->basePath . '/dependencies/composer/installed.json', json_encode(['packages' => [ + ['name' => 'example/dependency', 'require' => ['ext-hypervel_doctor_virtual' => '^2.3']], + ['name' => 'example/provider', ...$provider], + ]], JSON_THROW_ON_ERROR)); + + $result = $this->app->make(RequiredPhpExtensionsAreLoaded::class)->check(); + + $this->assertSame($status, $result->status->value); + + if ($status === 'fail') { + $this->assertStringContainsString('example/dependency requires ^2.3', $result->details); + } + } + + /** + * Provide Composer range and self-version links. + */ + public static function extensionProviders(): array + { + return [ + 'provided range overlaps' => [['provide' => ['ext-hypervel_doctor_virtual' => '^2.0']], 'pass'], + 'replacement self version' => [[ + 'replace' => ['ext-hypervel_doctor_virtual' => 'self.version'], 'version_normalized' => '2.4.0.0', + ], 'pass'], + 'incompatible provider' => [['provide' => ['ext-hypervel_doctor_virtual' => '^1.0']], 'fail'], + 'no provider' => [[], 'fail'], + ]; + } + + public function testRootProviderCanSatisfyRequirementsWithoutInstalledMetadata(): void + { + file_put_contents($this->basePath . '/composer.json', json_encode([ + 'require' => ['ext-hypervel_doctor_virtual' => '^2.3'], + 'provide' => ['ext-hypervel_doctor_virtual' => '^2.0'], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame('pass', $this->app->make(RequiredPhpExtensionsAreLoaded::class)->check()->status->value); + } + + public function testSkipsWhenNoRootOrInstalledMetadataIsAvailable(): void + { + $this->assertSame('skip', $this->app->make(RequiredPhpExtensionsAreLoaded::class)->check()->status->value); + } + + public function testOneProviderMustSatisfyEveryRequiringPackage(): void + { + file_put_contents($this->basePath . '/dependencies/composer/installed.json', json_encode(['packages' => [ + ['name' => 'example/first', 'require' => ['ext-hypervel_doctor_virtual' => '^1.0']], + ['name' => 'example/second', 'require' => ['ext-hypervel_doctor_virtual' => '^2.0']], + ['name' => 'example/provider-first', 'provide' => ['ext-hypervel_doctor_virtual' => '^1.0']], + ['name' => 'example/provider-second', 'provide' => ['ext-hypervel_doctor_virtual' => '^2.0']], + ]], JSON_THROW_ON_ERROR)); + + $result = $this->app->make(RequiredPhpExtensionsAreLoaded::class)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertStringContainsString('example/first requires ^1.0', $result->details); + $this->assertStringContainsString('example/second requires ^2.0', $result->details); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/ScheduledTasksRequireSchedulerTest.php b/tests/Doctor/Feature/Diagnostics/ScheduledTasksRequireSchedulerTest.php new file mode 100644 index 0000000000..3598c66d58 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/ScheduledTasksRequireSchedulerTest.php @@ -0,0 +1,38 @@ +app->instance(Schedule::class, new Schedule); + + $result = (new ScheduledTasksRequireScheduler)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame('The application does not have scheduled tasks.', $result->summary); + } + + public function testNoticesWhenScheduledTasksAreRegistered(): void + { + $schedule = new Schedule; + $schedule->call(fn (): bool => true)->everyMinute(); + + $this->app->instance(Schedule::class, $schedule); + + $result = (new ScheduledTasksRequireScheduler)->check(); + + $this->assertSame('notice', $result->status->value); + $this->assertSame('The application has scheduled tasks.', $result->summary); + $this->assertSame('Keep `php artisan schedule:run` running; supervise this long-running process in production.', $result->remediation); + $this->assertEquals([Link::docs('scheduling', 'running-the-scheduler')], $result->links); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/SessionDriverIsReachableTest.php b/tests/Doctor/Feature/Diagnostics/SessionDriverIsReachableTest.php new file mode 100644 index 0000000000..9d20f330b3 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/SessionDriverIsReachableTest.php @@ -0,0 +1,138 @@ +path = ParallelTesting::tempDir('DoctorSession'); + File::deleteDirectory($this->path); + File::makeDirectory($this->path, 0775, true); + } + + /** + * Remove the session directory. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->path); + } finally { + parent::tearDown(); + } + } + + #[DataProvider('fileDrivers')] + public function testPassesWhenFileSessionsCanBeReached(string $driver): void + { + config([ + 'session.driver' => $driver, + 'session.files' => $this->path, + ]); + + $result = (new SessionDriverIsReachable)->check(); + + $this->assertSame('pass', $result->status->value); + } + + #[DataProvider('fileDrivers')] + public function testLinksToSessionConfigurationWhenTheDriverCannotBeReached(string $driver): void + { + config([ + 'session.driver' => $driver, + 'session.files' => $this->path . '/missing', + ]); + + $result = (new SessionDriverIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertEquals([Link::docs('session', 'configuration')], $result->links); + } + + /** + * Provide file-backed session driver names. + */ + public static function fileDrivers(): array + { + return [['file'], ['native']]; + } + + #[DataProvider('redisConnections')] + public function testUsesTheEffectiveRedisSessionConnection(?string $connection, string $expected): void + { + config(['session.driver' => 'redis', 'session.connection' => $connection]); + $redis = new class { + public ?string $selected = null; + + /** + * Record the selected Redis connection. + */ + public function connection(string $name): static + { + $this->selected = $name; + + return $this; + } + + /** + * Respond to the diagnostic probe. + */ + public function ping(): string + { + return 'PONG'; + } + }; + Redis::swap($redis); + + $this->assertSame('pass', (new SessionDriverIsReachable)->check()->status->value); + $this->assertSame($expected, $redis->selected); + } + + /** + * Provide explicit and omitted Redis session connection choices. + */ + public static function redisConnections(): array + { + return [[null, 'session'], ['', 'session'], ['custom', 'custom']]; + } + + public function testDatabaseSessionsKeepTheDefaultSchemaConnection(): void + { + config(['session.driver' => 'database', 'session.connection' => null, 'session.table' => 'custom_sessions']); + $schema = m::mock(Builder::class); + $schema->expects('hasTable')->with('custom_sessions')->andReturnTrue(); + Schema::shouldReceive('connection')->with(null)->andReturn($schema); + + $this->assertSame('pass', (new SessionDriverIsReachable)->check()->status->value); + } + + public function testDatabaseSessionsRequireAnExplicitTableSetting(): void + { + config(['session.driver' => 'database', 'session.table' => null]); + + $result = (new SessionDriverIsReachable)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame('The database session table is not configured.', $result->details); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/SqliteDatabaseExistsTest.php b/tests/Doctor/Feature/Diagnostics/SqliteDatabaseExistsTest.php new file mode 100644 index 0000000000..38ce41a4e9 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/SqliteDatabaseExistsTest.php @@ -0,0 +1,205 @@ +basePath = ParallelTesting::tempDir('DoctorSqlite'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->setBasePath($this->basePath); + } + + /** + * Remove the temporary databases. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenTheSqliteDatabaseFileExists(): void + { + $basePath = $this->basePath; + mkdir($basePath . '/database'); + touch($basePath . '/database/database.sqlite'); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => 'database/database.sqlite', + ]); + + $result = (new SqliteDatabaseExists)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame($basePath . '/database/database.sqlite', $result->context['database']); + } + + #[DataProvider('windowsPaths')] + public function testPreservesAnAbsoluteWindowsSqliteDatabasePath(string $database): void + { + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $database, + ]); + + $result = (new SqliteDatabaseExists)->check(); + + $this->assertSame('fail', $result->status->value); + $this->assertSame($database, $result->context['database']); + } + + /** + * Provide absolute Windows path variants. + */ + public static function windowsPaths(): array + { + return [ + 'backslash separators' => ['C:\Users\MyUser\Herd\my-project\database\database.sqlite'], + 'forward slash separators' => ['C:/Users/MyUser/Herd/my-project/database/database.sqlite'], + ]; + } + + #[DataProvider('splitDatabaseRoles')] + public function testInspectsAndRepairsTheSelectedSplitDatabase(string $suffix, string $database): void + { + config([ + 'database.default' => 'testing' . $suffix, + 'database.connections.testing' => [ + 'driver' => 'sqlite', 'database' => $this->basePath . '/base.sqlite', + 'write' => ['database' => $this->basePath . '/writer.sqlite'], + 'read' => ['database' => $this->basePath . '/reader.sqlite'], + ], + ]); + $diagnostic = new SqliteDatabaseExists; + $result = $diagnostic->check(); + + $this->assertSame('sqlite-database-exists.missing', $result->code); + $this->assertSame($this->basePath . '/' . $database, $result->context['database']); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + $this->assertFileExists($this->basePath . '/' . $database); + $this->assertFileDoesNotExist($this->basePath . '/base.sqlite'); + $this->assertSame('pass', $diagnostic->check()->status->value); + } + + /** + * Provide selected database roles and their effective paths. + */ + public static function splitDatabaseRoles(): array + { + return [['', 'writer.sqlite'], ['::write', 'writer.sqlite'], ['::read', 'reader.sqlite']]; + } + + public function testReportsAMissingSqliteDatabaseFileAndCarriesThePathToTheFix(): void + { + $basePath = $this->basePath; + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => 'database/database.sqlite', + ]); + + $diagnostic = new SqliteDatabaseExists; + $result = $diagnostic->check(); + + config(['database.connections.sqlite.database' => 'database/changed.sqlite']); + + $fix = $diagnostic->fix($result); + + $this->assertSame('fail', $result->status->value); + $this->assertTrue($result->fixable); + $this->assertSame($basePath . '/database/database.sqlite', $result->context['database']); + $this->assertEquals([Link::docs('database', 'configuration')], $result->links); + $this->assertSame('pass', $fix->status->value); + $this->assertFileExists($basePath . '/database/database.sqlite'); + $this->assertFileDoesNotExist($basePath . '/database/changed.sqlite'); + } + + #[DataProvider('nonFileDatabases')] + public function testDoesNotOfferToCreateMemoryDatabasesOrUris(string $database, string $code): void + { + config([ + 'database.default' => 'analytics', + 'database.connections.analytics' => ['driver' => 'sqlite', 'database' => $database], + ]); + + $result = (new SqliteDatabaseExists)->check(); + + $this->assertSame('skip', $result->status->value); + $this->assertSame('sqlite-database-exists.' . $code, $result->code); + $this->assertFalse($result->fixable); + $this->assertSame([], File::files($this->basePath)); + } + + /** + * Provide SQLite memory names and connector-owned URI modes. + */ + public static function nonFileDatabases(): array + { + return [ + [':memory:', 'not-file'], + ['file::memory:?cache=shared', 'not-file'], + ['file:analytics?mode=memory&cache=shared', 'not-file'], + ['file:analytics.sqlite?mode=ro', 'uri'], + ['file:analytics.sqlite?mode=rwc', 'uri'], + ]; + } + + public function testUsesTheNormalizedDriverFromADatabaseUrl(): void + { + $database = $this->basePath . '/analytics.sqlite'; + touch($database); + config([ + 'database.default' => 'analytics', + 'database.connections.analytics' => ['driver' => 'mysql', 'url' => 'sqlite:///' . $database], + ]); + + $result = (new SqliteDatabaseExists)->check(); + + $this->assertSame('pass', $result->status->value); + $this->assertSame($database, $result->context['database']); + } + + public function testRepairPreservesAFileCreatedAfterItsInitialCheck(): void + { + $database = $this->basePath . '/database.sqlite'; + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $database, + ]); + $diagnostic = new SqliteDatabaseExists; + $result = $diagnostic->check(); + + File::partialMock()->shouldReceive('ensureDirectoryExists') + ->once()->with($this->basePath) + ->andReturnUsing(function () use ($database): void { + file_put_contents($database, 'database created by another process'); + }); + + $fix = $diagnostic->fix($result); + + $this->assertSame('sqlite-database-exists.fix.already-exists', $fix->code); + $this->assertSame('database created by another process', file_get_contents($database)); + } +} diff --git a/tests/Doctor/Feature/Diagnostics/StorageIsWritableTest.php b/tests/Doctor/Feature/Diagnostics/StorageIsWritableTest.php new file mode 100644 index 0000000000..8247acf985 --- /dev/null +++ b/tests/Doctor/Feature/Diagnostics/StorageIsWritableTest.php @@ -0,0 +1,193 @@ +basePath = ParallelTesting::tempDir('DoctorWritableStorage'); + File::deleteDirectory($this->basePath); + File::makeDirectory($this->basePath, 0775, true); + $this->app->setBasePath($this->basePath); + $this->app->useStoragePath($this->basePath . '/storage'); + } + + /** + * Remove the temporary storage tree. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->basePath); + } finally { + parent::tearDown(); + } + } + + public function testPassesWhenHypervelCanWriteToTheRequiredStorageDirectories(): void + { + foreach ($this->storagePaths() as $path) { + File::ensureDirectoryExists($path); + } + + $report = $this->app->make(Doctor::class) + ->only('StorageIsWritable')->run(); + + $outcome = $report->diagnostics()[0]; + + $this->assertSame(StorageIsWritable::class, $outcome->diagnostic::class); + $this->assertSame('pass', $outcome->result->status->value); + $this->assertSame('The application storage directories are writable.', $outcome->result->summary); + } + + public function testReportsMissingWritableStorageDirectories(): void + { + $report = $this->app->make(Doctor::class) + ->only('StorageIsWritable')->run(); + + $outcome = $report->diagnostics()[0]; + + $this->assertSame('fail', $outcome->result->status->value); + $this->assertStringContainsString($this->basePath . '/storage: directory does not exist', $outcome->result->details); + $this->assertSame('Make the storage directories writable?', $outcome->result->confirmation); + $this->assertSame('Ensure the listed storage and bootstrap cache directories exist and are writable by the PHP process.', $outcome->result->remediation); + } + + public function testCycleBesideAHealthyChildCannotPassDiscovery(): void + { + config([ + 'cache.default' => 'main', + 'cache.stores.main' => ['driver' => 'failover', 'stores' => ['main', 'array']], + ]); + + $result = $this->app->make(Doctor::class)->check(new StorageIsWritable)->result; + + $this->assertSame('error', $result->status->value); + $this->assertSame('Circular cache store definition detected: main -> main.', $result->summary); + } + + public function testCreatesMissingWritableStorageDirectoriesWhenFixed(): void + { + $fix = $this->app->make(Doctor::class)->fix(new DiagnosticOutcome( + new StorageIsWritable, + DiagnosticResult::fail('The application cannot write to every required storage directory.')->fixable(), + )); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame('The application storage directories are writable.', $fix->result->summary); + + foreach ($this->storagePaths() as $path) { + $this->assertDirectoryExists($path); + $this->assertTrue(is_writable($path)); + } + } + + public function testRepairsRelocatedPathsWithoutChangingHealthyDirectoryPermissions(): void + { + $shared = $this->basePath . '/shared'; + mkdir($shared); + chmod($shared, 01777); + $this->app->useStoragePath($this->basePath . '/application-storage'); + config([ + 'view.compiled' => $shared, + 'session.driver' => 'file', + 'session.files' => $this->basePath . '/sessions', + 'cache.default' => 'stack', + 'cache.stores.stack' => ['driver' => 'stack', 'stores' => ['files']], + 'cache.stores.files' => [ + 'driver' => 'file', + 'path' => $this->basePath . '/cache-data', + 'lock_path' => $this->basePath . '/cache-locks', + ], + ]); + $environment = Env::getRepository(); + $previous = $environment->get('APP_CONFIG_CACHE'); + $environment->set('APP_CONFIG_CACHE', $this->basePath . '/artifacts/config.php'); + + try { + $diagnostic = new StorageIsWritable; + $result = $diagnostic->check(); + $this->assertSame('fail', $result->status->value); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + + foreach (['application-storage/framework', 'sessions', 'cache-data', 'cache-locks', 'artifacts'] as $relative) { + $this->assertDirectoryExists($this->basePath . '/' . $relative); + } + + clearstatcache(true, $shared); + $this->assertSame(01777, fileperms($shared) & 07777); + $this->assertSame('pass', $diagnostic->check()->status->value); + $this->assertSame([], File::files($shared, true)); + } finally { + $previous === null ? $environment->clear('APP_CONFIG_CACHE') : $environment->set('APP_CONFIG_CACHE', $previous); + } + } + + public function testRepairAddsOwnerAccessWithoutWideningOtherPermissions(): void + { + if (! function_exists('posix_geteuid') || posix_geteuid() === 0) { + $this->markTestSkipped('Requires Unix file permissions for an unprivileged user.'); + } + + foreach ($this->storagePaths() as $path) { + File::ensureDirectoryExists($path); + } + + $private = $this->basePath . '/private-views'; + mkdir($private); + chmod($private, 01550); + config(['view.compiled' => $private]); + + try { + $diagnostic = new StorageIsWritable; + $result = $diagnostic->check(); + $this->assertSame('fail', $result->status->value); + $this->assertSame('pass', $diagnostic->fix($result)->status->value); + + clearstatcache(true, $private); + $this->assertSame(01750, fileperms($private) & 07777); + } finally { + chmod($private, 0700); + } + } + + /** + * Get the required storage directories and resolved bootstrap cache parents. + * + * @return list + */ + private function storagePaths(): array + { + return [ + $this->basePath . '/storage', + $this->basePath . '/storage/app', + $this->basePath . '/storage/framework', + $this->basePath . '/storage/framework/cache', + $this->basePath . '/storage/framework/sessions', + $this->basePath . '/storage/framework/views', + $this->basePath . '/storage/logs', + dirname($this->app->getCachedConfigPath()), + dirname($this->app->getCachedRoutesPath()), + dirname($this->app->getCachedEventsPath()), + dirname($this->app->getCachedPackagesPath()), + ]; + } +} diff --git a/tests/Doctor/Feature/DoctorServiceProviderTest.php b/tests/Doctor/Feature/DoctorServiceProviderTest.php new file mode 100644 index 0000000000..078289bcee --- /dev/null +++ b/tests/Doctor/Feature/DoctorServiceProviderTest.php @@ -0,0 +1,1168 @@ +temporaryPath = ParallelTesting::tempDir('DoctorCommands'); + File::deleteDirectory($this->temporaryPath); + File::makeDirectory($this->temporaryPath, 0775, true); + } + + /** + * Remove command fixtures. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->temporaryPath); + } finally { + parent::tearDown(); + } + } + + public function testLoadsThePackageServiceProvider(): void + { + $this->assertNotNull($this->app->getProvider(DoctorServiceProvider::class)); + } + + public function testDoesNotRedefineSymfonyConsoleVerbosityOptions(): void + { + $command = new DoctorCommand; + + $this->assertFalse($command->getDefinition()->hasOption('verbose')); + $this->assertTrue($command->getDefinition()->hasOption('format')); + $this->assertTrue($command->getDefinition()->hasOption('bail')); + } + + public function testLoadsTheDefaultDoctorConfiguration(): void + { + $this->assertSame([], config('doctor.only')); + $this->assertSame([], config('doctor.except')); + $this->assertSame([ + 'local' => ['local'], + 'production' => ['production', 'staging'], + ], config('doctor.environments')); + } + + // REMOVED: timezone command termination; Hypervel rejects invalid timezones during boot. + + public function testDoesNotRepeatDiagnosticsThatWereFixed(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + config(['app.key' => '']); + + $environmentPath = $this->app->basePath(); + $path = $environmentPath . '/.env'; + $originalEnvironmentFile = is_file($path) ? File::get($path) : null; + File::put($path, "APP_KEY=\n"); + + $this->app->useEnvironmentPath($environmentPath); + $this->app->loadEnvironmentFrom('.env'); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + try { + $exitCode = $this->withEnvironmentValues([ + 'APP_KEY' => null, + ], fn () => $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'ApplicationKeyIsSet', + '--fix' => true, + ], $output)); + + $this->assertStringContainsString('base64:', File::get($path)); + } finally { + $originalEnvironmentFile === null ? File::delete($path) : File::put($path, $originalEnvironmentFile); + } + + $contents = $output->fetch(); + + $this->assertStringContainsString('Re-running diagnostics after applying fixes...', $contents); + $this->assertMatchesRegularExpression('/Re-running diagnostics after applying fixes\.\.\..*Results.*App key is set/s', $contents); + $this->assertStringContainsString('App key is set', $contents); + $this->assertStringContainsString('The application key was generated.', $contents); + $this->assertStringNotContainsString('App key is set: The application key was generated.', $contents); + $this->assertStringContainsString('All diagnostics passed or were fixed.', $contents); + $this->assertSame(0, $exitCode); + } + + public function testRerunsDiagnosticsAfterApplyingFixes(): void + { + Doctor::diagnostic(FixesSharedStateDiagnostic::class); + Doctor::diagnostic(SharedStateWasFixedDiagnostic::class); + + config(['doctor-testing.shared-state-fixed' => false]); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'testing', + '--fix' => true, + ], $output); + + $contents = $output->fetch(); + $this->assertStringNotContainsString('Fix', $contents); + $this->assertStringContainsString('Re-running diagnostics after applying fixes...', $contents); + $this->assertStringContainsString('Testing diagnostic fixes shared state', $contents); + $this->assertStringContainsString('The shared state fix', $contents); + $this->assertStringContainsString('ran.', $contents); + $this->assertStringContainsString('All diagnostics passed or were fixed.', $contents); + $this->assertStringNotContainsString('[pass] fix', $contents); + $this->assertStringNotContainsString('The shared state is still broken.', $contents); + $this->assertSame(0, $exitCode); + } + + public function testMergesAFailedFixIntoTheDiagnosticIssueCallout(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + config(['app.key' => '']); + + $environmentPath = $this->temporaryPath . '/key-fail'; + + mkdir($environmentPath, 0775, true); + + $this->app->useEnvironmentPath($environmentPath); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'ApplicationKeyIsSet', + '--fix' => true, + ], $output); + + $contents = $output->fetch(); + + $this->assertStringContainsString('The application key is not configured.', $contents); + $this->assertStringContainsString('Fix failed', $contents); + $this->assertStringContainsString('The application key could not be generated.', $contents); + $this->assertStringContainsString('could not be updated', $contents); + $this->assertStringNotContainsString('Suggested fix', $contents); + $this->assertStringContainsString('Doctor found failing diagnostics.', $contents); + $this->assertSame(1, substr_count($contents, 'The application key could not be generated.')); + $this->assertSame(1, $exitCode); + } + + public function testEscalatesWarningIssueCalloutsToErrorsWhenTheirFixFailed(): void + { + $renderer = new class(new OutputStyle(new ArrayInput([]), new NullOutput)) extends CliRenderer { + /** + * Expose the rendered callout type. + */ + public function type(DiagnosticOutcome $outcome, ?DiagnosticFixOutcome $failedFix): string + { + return $this->issueCalloutType($outcome, $failedFix); + } + }; + + $outcome = new DiagnosticOutcome( + new FixableDiagnostic, + DiagnosticResult::warn('The diagnostic partially improved.'), + ); + + $failedFix = new DiagnosticFixOutcome( + new FixableDiagnostic, + new FixResult(Status::Fail, 'The fix failed.'), + ); + + $this->assertSame('warning', $renderer->type($outcome, null)); + $this->assertSame('error', $renderer->type($outcome, $failedFix)); + } + + public function testFormatsInteractiveFixCalloutsWithoutRemediationOrConfirmationText(): void + { + $renderer = new class(new OutputStyle(new ArrayInput([]), new NullOutput)) extends CliRenderer { + /** + * Expose the fix confirmation content. + */ + public function content(DiagnosticOutcome $outcome): array + { + return $this->fixConfirmationCalloutContent($outcome); + } + }; + + $command = new class extends DoctorCommand { + /** + * Expose the confirmation prompt. + */ + public function prompt(DiagnosticOutcome $outcome): string + { + return $this->confirmationPrompt($outcome); + } + }; + + $outcome = new DiagnosticOutcome( + new FixableDiagnostic, + DiagnosticResult::fail('The diagnostic failed.') + ->suggest('Apply the testing diagnostic fix.') + ->confirmUsing('Fix the testing diagnostic?'), + ); + + $outcomeWithoutConfirmation = new DiagnosticOutcome( + new FixableDiagnostic, + DiagnosticResult::fail('The diagnostic failed.')->fixable(), + ); + + $this->assertSame(['The diagnostic failed.'], $renderer->content($outcome)); + $this->assertSame('Fix the testing diagnostic?', $command->prompt($outcome)); + $this->assertSame('Fix "Testing diagnostic is fixable"?', $command->prompt($outcomeWithoutConfirmation)); + } + + public function testRendersIssueDetailsUnderAHeadingDistinctFromTheSuggestedFix(): void + { + $renderer = new class(new OutputStyle(new ArrayInput([]), new NullOutput)) extends CliRenderer { + /** + * Expose the diagnostic callout content. + */ + public function content(DiagnosticOutcome $outcome): array + { + return $this->diagnosticCalloutContent($outcome); + } + }; + + $outcome = new DiagnosticOutcome( + new FixableDiagnostic, + DiagnosticResult::fail('The diagnostic failed.') + ->withDetails('- The lock file is not up to date with the latest changes in composer.json.') + ->suggest('Run `composer update --lock`.'), + ); + + $this->assertEquals([ + 'The diagnostic failed.', + Element::heading('Details'), + '- The lock file is not up to date with the latest changes in composer.json.', + Element::heading('Suggested fix'), + 'Run `composer update --lock`.', + ], $renderer->content($outcome)); + } + + public function testRendersRelatedLinksBeneathTheSuggestedFixAsPromptLinksWithShortenedUrls(): void + { + $renderer = new class(new OutputStyle(new ArrayInput([]), new NullOutput)) extends CliRenderer { + /** + * Expose the diagnostic callout content. + */ + public function content(DiagnosticOutcome $outcome): array + { + return $this->diagnosticCalloutContent($outcome); + } + + /** + * Expose the notice callout content. + * + * @param list $outcomes + */ + public function notices(array $outcomes): array + { + return $this->noticesCalloutContent($outcomes); + } + }; + + $outcome = new DiagnosticOutcome(new LinkedDiagnostic, (new LinkedDiagnostic)->check()); + + $this->assertEquals([ + 'The linked diagnostic warned.', + Element::heading('Details'), + 'Detailed link context.', + Element::heading('Suggested fix'), + 'Follow the linked documentation.', + Element::link( + 'https://hypervel.org/docs/queues#connections-vs-queues', + 'hypervel.org/docs/queues', + ), + Element::link('https://example.com/links', 'example.com/links'), + ], $renderer->content($outcome)); + $this->assertEquals([ + 'The linked diagnostic warned. Detailed link context. Follow the linked documentation.', + Element::link( + 'https://hypervel.org/docs/queues#connections-vs-queues', + 'hypervel.org/docs/queues', + ), + Element::link('https://example.com/links', 'example.com/links'), + ], $renderer->notices([$outcome])); + } + + public function testRendersIssueCalloutSourcesWithPackageFooter(): void + { + config(['app.key' => '']); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, true); + + $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'ApplicationKeyIsSet', + '--fail-on' => 'fail', + '--no-interaction' => true, + ], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('Results', $contents); + $this->assertStringContainsString('The application key is not configured.', $contents); + $this->assertStringContainsString('Suggested fix', $contents); + $this->assertStringContainsString('Generate an application key with', $contents); + $this->assertStringContainsString('php artisan key:generate', $contents); + $this->assertStringContainsString('https://hypervel.org/docs/encryption', $contents); + $this->assertStringContainsString('hypervel/components', $contents); + $this->assertStringNotContainsString('docs:', $contents); + $this->assertStringNotContainsString('Encryption documentation', $contents); + $this->assertStringNotContainsString('Links', $contents); + $this->assertStringNotContainsString('Confirmation', $contents); + $this->assertStringNotContainsString('Generate an application key using `php artisan key:generate`?', $contents); + $this->assertStringNotContainsString('File:', $contents); + $this->assertStringNotContainsString('ApplicationKeyIsSet.php', $contents); + $this->assertStringNotContainsString('hypervel/components ApplicationKeyIsSet.php', $contents); + } + + public function testRendersNoticeDiagnosticsWithoutDiagnosticSourceNoise(): void + { + $basePath = $this->temporaryPath . '/notice-output'; + + mkdir($basePath . '/bootstrap/cache', 0775, true); + mkdir($basePath . '/storage/framework/views', 0775, true); + + $this->app->setBasePath($basePath); + $this->app->useStoragePath($basePath . '/storage'); + $this->app->detectEnvironment(fn (): string => 'local'); + config(['view.compiled' => $basePath . '/storage/framework/views']); + + file_put_contents($this->app->getCachedEventsPath(), 'app->make(Kernel::class)->call('doctor', ['--only' => 'BootstrapCacheMatchesEnvironment'], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('Notice', $contents); + $this->assertStringContainsString('Cached bootstrap file detected: events.', $contents); + $this->assertStringContainsString('optimize:clear', $contents); + $this->assertStringContainsString('https://hypervel.org/docs/deployment#optimization', $contents); + $this->assertStringNotContainsString('Suggested fix', $contents); + $this->assertStringNotContainsString('docs:', $contents); + $this->assertStringNotContainsString('Configuration documentation', $contents); + $this->assertStringNotContainsString('Links', $contents); + $this->assertStringNotContainsString('Notes:', $contents); + $this->assertStringNotContainsString('[notice]', $contents); + $this->assertStringNotContainsString('Bootstrap cache matches environment (hypervel/components)', $contents); + $this->assertSame(0, $exitCode); + } + + public function testRendersMultipleNoticeDiagnosticsInASingleCallout(): void + { + $basePath = $this->temporaryPath . '/notices-output'; + + mkdir($basePath . '/bootstrap/cache', 0775, true); + mkdir($basePath . '/storage/framework/views', 0775, true); + + $this->app->setBasePath($basePath); + $this->app->useStoragePath($basePath . '/storage'); + $this->app->detectEnvironment(fn (): string => 'local'); + config([ + 'queue.default' => 'database', + 'view.compiled' => $basePath . '/storage/framework/views', + ]); + + file_put_contents($this->app->getCachedEventsPath(), 'app->make(Kernel::class)->call('doctor', [ + '--only' => [ + 'BootstrapCacheMatchesEnvironment', + 'QueueConnectionIsAsynchronous', + ], + ], $output); + + $contents = $output->fetch(); + + $this->assertStringContainsString('Notices', $contents); + $this->assertStringContainsString('Cached bootstrap file detected: events.', $contents); + $this->assertStringContainsString('Queued jobs are processed asynchronously.', $contents); + $this->assertStringContainsString('hypervel/components', $contents); + $this->assertStringContainsString('optimize:clear', $contents); + $this->assertStringContainsString('queue:work', $contents); + $this->assertMatchesRegularExpression('/Cached bootstrap file detected.*optimize:clear.*https:\/\/hypervel\.org\/docs\/deployment#optimization.*Queued jobs are processed asynchronously.*queue:work.*https:\/\/hypervel\.org\/docs\/queues#running-the-queue-worker/s', $contents); + $this->assertStringNotContainsString('Suggested fix', $contents); + $this->assertStringNotContainsString('docs:', $contents); + $this->assertStringNotContainsString('Configuration documentation', $contents); + $this->assertStringNotContainsString('Queues documentation', $contents); + $this->assertStringNotContainsString('Links', $contents); + $this->assertSame(1, substr_count($contents, 'Notice')); + $this->assertSame(0, $exitCode); + } + + public function testGroupsNoticeDiagnosticsByPackageSource(): void + { + Doctor::diagnostic(PackagedNoticeDiagnostic::class); + + $basePath = $this->temporaryPath . '/notice-packages'; + + mkdir($basePath . '/bootstrap/cache', 0775, true); + mkdir($basePath . '/storage/framework/views', 0775, true); + + $this->app->setBasePath($basePath); + $this->app->useStoragePath($basePath . '/storage'); + $this->app->detectEnvironment(fn (): string => 'local'); + config([ + 'queue.default' => 'database', + 'view.compiled' => $basePath . '/storage/framework/views', + ]); + + file_put_contents($this->app->getCachedEventsPath(), 'app->make(Kernel::class)->call('doctor', [ + '--only' => [ + 'BootstrapCacheMatchesEnvironment', + 'QueueConnectionIsAsynchronous', + 'PackagedNoticeDiagnostic', + ], + ], $output); + + $contents = $output->fetch(); + + $this->assertStringContainsString('Cached bootstrap file detected: events.', $contents); + $this->assertStringContainsString('Queued jobs are processed asynchronously.', $contents); + $this->assertStringContainsString('The packaged diagnostic noticed.', $contents); + $this->assertStringContainsString('hypervel/components', $contents); + $this->assertStringContainsString('vendor/package', $contents); + $this->assertSame(2, substr_count($contents, 'Notice')); + $this->assertSame(0, $exitCode); + } + + public function testRendersDiagnosticLinksInCliOutput(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, true); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', ['--only' => 'LinkedDiagnostic'], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('Testing diagnostic has links', $contents); + $this->assertStringContainsString('The linked diagnostic warned.', $contents); + $this->assertStringContainsString('Detailed link context.', $contents); + $this->assertStringContainsString('Follow the linked documentation.', $contents); + $this->assertStringNotContainsString('[warn]', $contents); + $this->assertStringContainsString('https://hypervel.org/docs/queues#connections-vs-queues', $contents); + $this->assertStringContainsString('https://example.com/links', $contents); + $this->assertStringNotContainsString('docs:', $contents); + $this->assertStringNotContainsString('Queues documentation', $contents); + $this->assertStringNotContainsString('Related links guide', $contents); + $this->assertStringNotContainsString('Links', $contents); + $this->assertSame(0, $exitCode); + } + + #[DataProvider('machineOutputFormats')] + public function testMachineOutputPreservesLiteralDiagnosticText(string $format, bool $decorated): void + { + $diagnostic = new class extends Diagnostic { + public string $group = 'testing'; + + /** + * Report literal text that resembles console formatting. + */ + public function check(): DiagnosticResult + { + return DiagnosticResult::fail('Literal\path \message'); + } + }; + Doctor::diagnostic($diagnostic::class); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, $decorated); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'testing', + '--format' => $format, + ], $output); + $contents = $output->fetch(); + + $this->assertSame(1, $exitCode); + $this->assertStringNotContainsString("\033[", $contents); + + if ($format === 'github') { + $this->assertStringContainsString($diagnostic->check()->summary, $contents); + + return; + } + + $payload = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + $this->assertSame($diagnostic->check()->summary, $payload[$format === 'json' ? 'diagnostics' : 'issues'][0]['summary']); + } + + /** + * Provide machine formats with both terminal output modes. + */ + public static function machineOutputFormats(): array + { + return [ + ['json', false], ['json', true], + ['agent', false], ['agent', true], + ['github', false], ['github', true], + ]; + } + + public function testRendersDiagnosticLinksInJsonOutput(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'LinkedDiagnostic', + '--format' => 'json', + ], $output); + + $payload = json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([ + 'label' => 'hypervel/components', + 'package' => 'hypervel/components', + 'file' => 'tests/Doctor/Fixtures/Diagnostics/LinkedDiagnostic.php', + 'application' => true, + ], $payload['diagnostics'][0]['source']); + $this->assertFalse($payload['diagnostics'][0]['fixable']); + $this->assertSame([ + 'Queues documentation' => 'https://hypervel.org/docs/queues#connections-vs-queues', + 'Related links guide' => 'https://example.com/links', + ], $payload['diagnostics'][0]['links']); + $this->assertSame(0, $exitCode); + } + + public function testBailsAfterTheFirstFailureFromTheCommand(): void + { + Doctor::diagnostic(WarningDiagnostic::class); + Doctor::diagnostic(FixableDiagnostic::class); + Doctor::diagnostic(PassingDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => [ + 'WarningDiagnostic', + 'FixableDiagnostic', + 'PassingDiagnostic', + ], + '--bail' => true, + '--format' => 'json', + ], $output); + + $payload = json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([ + WarningDiagnostic::class, + FixableDiagnostic::class, + ], array_column($payload['diagnostics'], 'class')); + $this->assertSame(1, $exitCode); + } + + public function testRendersDiagnosticLinksInGithubOutput(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'LinkedDiagnostic', + '--format' => 'github', + ], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('title=Testing diagnostic has links (hypervel/components)', $contents); + $this->assertStringContainsString('https://hypervel.org/docs/queues#connections-vs-queues', $contents); + $this->assertStringContainsString('https://example.com/links', $contents); + $this->assertStringNotContainsString('docs:', $contents); + $this->assertStringNotContainsString('Queues documentation', $contents); + $this->assertStringNotContainsString('Related links guide', $contents); + $this->assertSame(0, $exitCode); + } + + public function testRejectsFixesWithJsonOutput(): void + { + $this->artisan('doctor --only=ApplicationKeyIsSet --fix --format=json') + ->expectsOutputToContain('The --fix option may only be used with the cli or agent output formats.') + ->doesntExpectOutputToContain('"diagnostics"') + ->assertExitCode(1); + } + + public function testRejectsFixesWithGithubOutput(): void + { + $this->artisan('doctor --only=ApplicationKeyIsSet --fix --format=github') + ->expectsOutputToContain('The --fix option may only be used with the cli or agent output formats.') + ->doesntExpectOutputToContain('::') + ->assertExitCode(1); + } + + public function testRendersAgentOutputWhenAnAgentIsDetected(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + putenv('AI_AGENT=hypervel-doctor-test'); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'LinkedDiagnostic', + ], $output); + + $contents = trim($output->fetch()); + $payload = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + + $this->assertStringNotContainsString("\n", $contents); + $this->assertSame('doctor', $payload['tool']); + $this->assertSame('passed', $payload['result']); + $this->assertSame(1, $payload['diagnostics']); + $this->assertSame(1, $payload['warnings']); + $this->assertSame([[ + 'name' => 'Testing diagnostic has links', + 'status' => 'warn', + 'summary' => 'The linked diagnostic warned.', + 'details' => 'Detailed link context.', + 'fix' => 'Follow the linked documentation.', + 'links' => [ + 'Queues documentation' => 'https://hypervel.org/docs/queues#connections-vs-queues', + 'Related links guide' => 'https://example.com/links', + ], + ]], $payload['issues']); + $this->assertArrayNotHasKey('fixes', $payload); + $this->assertSame(0, $exitCode); + } + + public function testPrefersAnExplicitFormatOverAgentDetection(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + putenv('AI_AGENT=hypervel-doctor-test'); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'LinkedDiagnostic', + '--format' => 'cli', + ], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('Testing diagnostic has links', $contents); + $this->assertStringContainsString('Follow the linked documentation.', $contents); + $this->assertSame(0, $exitCode); + } + + public function testKeepsCliOutputForNonInteractiveRunsOutsideAgents(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'LinkedDiagnostic', + '--no-interaction' => true, + ], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('Testing diagnostic has links', $contents); + $this->assertStringContainsString('Follow the linked documentation.', $contents); + $this->assertSame(0, $exitCode); + } + + public function testRendersMinimalAgentOutputWhenEveryDiagnosticPasses(): void + { + Doctor::diagnostic(PassingDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'PassingDiagnostic', + '--format' => 'agent', + ], $output); + + $payload = json_decode(trim($output->fetch()), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([ + 'tool' => 'doctor', + 'result' => 'passed', + 'diagnostics' => 1, + 'failed' => 0, + 'warnings' => 0, + 'notices' => 0, + 'passed' => 1, + 'skipped' => 0, + ], $payload); + $this->assertSame(0, $exitCode); + } + + public function testMarksFixableIssuesInAgentOutput(): void + { + Doctor::diagnostic(FixableDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'FixableDiagnostic', + '--format' => 'agent', + ], $output); + + $payload = json_decode(trim($output->fetch()), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('failed', $payload['result']); + $this->assertSame([[ + 'name' => 'Testing diagnostic is fixable', + 'status' => 'fail', + 'summary' => 'The diagnostic failed.', + 'fix' => 'Apply the testing diagnostic fix.', + 'fixable' => true, + ]], $payload['issues']); + $this->assertArrayNotHasKey('fixes', $payload); + $this->assertSame(1, $exitCode); + } + + public function testDoesNotExposeOrApplyLocalOnlyFixesInProductionAgentOutput(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + Doctor::diagnostic(LocalOnlyFixableDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'LocalOnlyFixableDiagnostic', + '--format' => 'agent', + '--fix' => true, + ], $output); + + $payload = json_decode(trim($output->fetch()), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([[ + 'name' => 'Testing diagnostic is fixable locally', + 'status' => 'fail', + 'summary' => 'The local diagnostic failed.', + 'fix' => 'Apply the local testing diagnostic fix.', + ]], $payload['issues']); + $this->assertArrayNotHasKey('fixes', $payload); + $this->assertSame(1, $exitCode); + } + + public function testAppliesFixesAndRerunsForAgentOutputWhenRequested(): void + { + Doctor::diagnostic(FixesSharedStateDiagnostic::class); + + config(['doctor-testing.shared-state-fixed' => false]); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'FixesSharedStateDiagnostic', + '--format' => 'agent', + '--fix' => true, + ], $output); + + $payload = json_decode(trim($output->fetch()), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('passed', $payload['result']); + $this->assertSame(1, $payload['passed']); + $this->assertSame([[ + 'name' => 'Testing diagnostic fixes shared state', + 'status' => 'pass', + 'summary' => 'The shared state fix ran.', + ]], $payload['fixes']); + $this->assertArrayNotHasKey('issues', $payload); + $this->assertSame(0, $exitCode); + } + + public function testAppliesTheFixOptionSelectedInteractively(): void + { + Doctor::diagnostic(OptionFixableDiagnostic::class); + + $this->artisan('doctor --only=OptionFixableDiagnostic') + ->expectsChoice('Which option should fix the diagnostic?', 'a', [ + 'a' => 'Option A', + 'b' => 'Option B', + '__skip__' => 'Skip — leave unfixed', + ]) + ->expectsOutputToContain('The option diagnostic was fixed with [a].') + ->expectsOutputToContain('All diagnostics passed or were fixed.') + ->assertExitCode(0); + + $this->assertSame('a', config('doctor-testing.option-fixed')); + } + + public function testSkipsTheFixWhenTheInteractiveChoiceDeclinesIt(): void + { + Doctor::diagnostic(OptionFixableDiagnostic::class); + + $this->artisan('doctor --only=OptionFixableDiagnostic') + ->expectsChoice('Which option should fix the diagnostic?', '__skip__', [ + 'a' => 'Option A', + 'b' => 'Option B', + '__skip__' => 'Skip — leave unfixed', + ]) + ->expectsOutputToContain('The option diagnostic failed.') + ->assertExitCode(1); + + $this->assertNull(config('doctor-testing.option-fixed')); + } + + public function testPreservesNumericFixOptionKeys(): void + { + Doctor::diagnostic(OptionFixableDiagnostic::class); + config(['doctor-testing.options' => [42 => 'Selected option', 99 => 'Other option']]); + + $this->artisan('doctor --only=OptionFixableDiagnostic') + ->expectsChoice('Which option should fix the diagnostic?', '42', [ + 42 => 'Selected option', + 99 => 'Other option', + '__skip__' => 'Skip — leave unfixed', + ]) + ->assertSuccessful(); + + $this->assertSame('42', config('doctor-testing.option-fixed')); + } + + public function testKeepsTheUnreachableCacheStoreWhenItsDeclineEntryIsChosen(): void + { + // Prompt interception requires the app to stay in the testing environment, + // so map it to local mode instead of re-detecting the environment. + config(['doctor.environments' => ['local' => ['testing']]]); + + $path = $this->temporaryPath . '/decline'; + + mkdir($path, 0775, true); + + config(['cache' => [ + 'default' => 'redis', + 'prefix' => '', + 'stores' => [ + 'redis' => ['driver' => 'broken-driver'], + 'file' => ['driver' => 'file', 'path' => $path], + ], + ]]); + + $this->artisan('doctor --only=CacheStoreIsReachable') + ->expectsChoice('Which cache store should the application use?', '__skip__', [ + 'file' => 'File', + '__skip__' => 'Keep Redis (repair it manually)', + ]) + ->expectsOutputToContain('Check CACHE_STORE') + ->expectsOutputToContain('Doctor found failing diagnostics.') + ->assertExitCode(1); + + $this->assertSame('redis', config('cache.default')); + } + + #[DataProvider('interactionModes')] + public function testLeavesFixesThatRequireAChoiceUnfixedUnderFix(bool $interactive): void + { + Doctor::diagnostic(OptionFixableDiagnostic::class); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'OptionFixableDiagnostic', + '--fix' => true, + ...($interactive ? [] : ['--no-interaction' => true]), + ], $output); + + $contents = $output->fetch(); + $this->assertStringContainsString('The option diagnostic failed.', $contents); + $this->assertStringContainsString('Apply one of the diagnostic fix options.', $contents); + $this->assertNull(config('doctor-testing.option-fixed')); + $this->assertSame(1, $exitCode); + } + + /** + * Provide interactive and noninteractive command inputs. + */ + public static function interactionModes(): array + { + return [[true], [false]]; + } + + #[DataProvider('interactionModes')] + public function testPipedInputRequiresAnExplicitFixFlag(bool $fix): void + { + $script = $this->temporaryPath . '/piped-doctor.php'; + File::put($script, <<<'PHP' + make(\Hypervel\Contracts\Console\Kernel::class); + $kernel->bootstrap(); + $app->detectEnvironment(fn () => 'local'); + $app->register(\Hypervel\Doctor\DoctorServiceProvider::class); + $app->make(\Hypervel\Doctor\Doctor::class)->diagnostics([ + \Hypervel\Tests\Doctor\Fixtures\Diagnostics\FixesSharedStateDiagnostic::class, + \Hypervel\Tests\Doctor\Fixtures\Diagnostics\OptionFixableDiagnostic::class, + ]); + + $status = $kernel->call('doctor', [ + '--only' => 'testing', + '--format' => 'cli', + ...(($argv[1] ?? '') === '--fix' ? ['--fix' => true] : []), + ]); + + echo 'FIX_STATE=' . json_encode([ + 'fixed' => config('doctor-testing.shared-state-fixed', false), + 'option' => config('doctor-testing.option-fixed'), + ]) . PHP_EOL; + + exit($status); + PHP); + + $process = new Process( + [PHP_BINARY, $script, ...($fix ? ['--fix'] : [])], + $this->app->basePath(), + [ + 'HYPERVEL_AUTOLOAD_PATH' => dirname(__DIR__, 3) . '/vendor/autoload.php', + 'DOCTOR_TEST_BASE_PATH' => $this->app->basePath(), + ], + ); + $process->setInput(''); + $process->run(); + + $this->assertSame(1, $process->getExitCode(), $process->getErrorOutput()); + $this->assertStringContainsString( + 'FIX_STATE=' . json_encode(['fixed' => $fix, 'option' => null]), + $process->getOutput(), + ); + } + + public function testExposesFixOptionsInsteadOfTheFixableFlagInAgentOutput(): void + { + Doctor::diagnostic(OptionFixableDiagnostic::class); + Doctor::diagnostic(FixesSharedStateDiagnostic::class); + + config(['doctor-testing.shared-state-fixed' => false]); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => ['OptionFixableDiagnostic', 'FixesSharedStateDiagnostic'], + '--format' => 'agent', + '--fix' => true, + ], $output); + + $payload = json_decode(trim($output->fetch()), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([[ + 'name' => 'Testing diagnostic offers fix options', + 'status' => 'fail', + 'summary' => 'The option diagnostic failed.', + 'fix' => 'Apply one of the diagnostic fix options.', + 'options' => ['a' => 'Option A', 'b' => 'Option B'], + ]], $payload['issues']); + $this->assertSame([[ + 'name' => 'Testing diagnostic fixes shared state', + 'status' => 'pass', + 'summary' => 'The shared state fix ran.', + ]], $payload['fixes']); + $this->assertNull(config('doctor-testing.option-fixed')); + $this->assertSame(1, $exitCode); + } + + public function testValidatesFailOnBeforeRunningDiagnostics(): void + { + Doctor::diagnostic(LinkedDiagnostic::class); + + $this->artisan('doctor --only=LinkedDiagnostic --fail-on=broken') + ->expectsOutputToContain('The --fail-on option must be one of: fail, warn, never.') + ->doesntExpectOutputToContain('The linked diagnostic warned.') + ->assertExitCode(1); + } + + public function testAppliesConfiguredOnlySelectors(): void + { + config(['doctor.only' => ['ApplicationKeyIsSet']]); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--format' => 'json', + '--fail-on' => 'never', + ], $output); + + $payload = json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertCount(1, $payload['diagnostics']); + $this->assertSame(ApplicationKeyIsSet::class, $payload['diagnostics'][0]['class']); + $this->assertSame(0, $exitCode); + } + + public function testAppliesConfiguredExceptSelectors(): void + { + config([ + 'doctor.only' => ['ApplicationKeyIsSet', 'EnvironmentFileExists'], + 'doctor.except' => ['ApplicationKeyIsSet'], + ]); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--format' => 'json', + '--fail-on' => 'never', + ], $output); + + $payload = json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertCount(1, $payload['diagnostics']); + $this->assertSame(EnvironmentFileExists::class, $payload['diagnostics'][0]['class']); + $this->assertSame(0, $exitCode); + } + + public function testNarrowsConfiguredOnlySelectorsWithCommandOnlySelectors(): void + { + config(['doctor.only' => ['security']]); + + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + + $exitCode = $this->app->make(Kernel::class)->call('doctor', [ + '--only' => 'EnvironmentFileExists', + '--format' => 'json', + '--fail-on' => 'never', + ], $output); + + $payload = json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([], $payload['diagnostics']); + $this->assertSame(0, $exitCode); + } + + public function testBindsTheDoctorServiceAndFacade(): void + { + Doctor::diagnostic(PassingDiagnostic::class); + + $this->assertSame([ + EnvironmentFileExists::class, + ApplicationKeyIsSet::class, + PhpVersionSatisfiesComposerRequirement::class, + RequiredPhpExtensionsAreLoaded::class, + RecommendedPhpExtensionsAreLoaded::class, + ComposerAutoloadIsValid::class, + ComposerLockIsFresh::class, + ConfigurationFilesCanBeLoaded::class, + ConfigurationCanBeCached::class, + RequiredConfigurationValuesAreSet::class, + BootstrapCacheMatchesEnvironment::class, + DatabaseConnectionIsReachable::class, + SqliteDatabaseExists::class, + MigrationsAreUpToDate::class, + CacheStoreIsReachable::class, + RedisConnectionsAreReachable::class, + RedisSupportsConfiguredFeatures::class, + QueueConnectionIsReachable::class, + QueueConnectionIsAsynchronous::class, + SessionDriverIsReachable::class, + ScheduledTasksRequireScheduler::class, + PublicStorageLinkExists::class, + FilesystemDisksAreReachable::class, + StorageIsWritable::class, + DebugModeMatchesEnvironment::class, + EnvironmentFileIsGitIgnored::class, + ComposerAuditPasses::class, + PassingDiagnostic::class, + ], $this->app->make(DoctorService::class)->registered()); + } + + public function testRegistersTheDefaultDiagnostics(): void + { + $this->assertSame([ + EnvironmentFileExists::class, + ApplicationKeyIsSet::class, + PhpVersionSatisfiesComposerRequirement::class, + RequiredPhpExtensionsAreLoaded::class, + RecommendedPhpExtensionsAreLoaded::class, + ComposerAutoloadIsValid::class, + ComposerLockIsFresh::class, + ConfigurationFilesCanBeLoaded::class, + ConfigurationCanBeCached::class, + RequiredConfigurationValuesAreSet::class, + BootstrapCacheMatchesEnvironment::class, + DatabaseConnectionIsReachable::class, + SqliteDatabaseExists::class, + MigrationsAreUpToDate::class, + CacheStoreIsReachable::class, + RedisConnectionsAreReachable::class, + RedisSupportsConfiguredFeatures::class, + QueueConnectionIsReachable::class, + QueueConnectionIsAsynchronous::class, + SessionDriverIsReachable::class, + ScheduledTasksRequireScheduler::class, + PublicStorageLinkExists::class, + FilesystemDisksAreReachable::class, + StorageIsWritable::class, + DebugModeMatchesEnvironment::class, + EnvironmentFileIsGitIgnored::class, + ComposerAuditPasses::class, + ], $this->app->make(DoctorService::class)->registered()); + } +} diff --git a/tests/Doctor/Feature/Results/LinkTest.php b/tests/Doctor/Feature/Results/LinkTest.php new file mode 100644 index 0000000000..a848b7e2a4 --- /dev/null +++ b/tests/Doctor/Feature/Results/LinkTest.php @@ -0,0 +1,64 @@ +assertSame('Queues documentation', $link->label); + $this->assertSame('https://hypervel.org/docs/queues', $link->url); + $this->assertSame('https://hypervel.org/docs/queues', $link->agentUrl); + } + + public function testCreatesSectionLinksWithCustomLabels(): void + { + $link = Link::docs('queues', 'connections-vs-queues', 'Queue connections'); + + $this->assertSame('Queue connections', $link->label); + $this->assertSame('https://hypervel.org/docs/queues#connections-vs-queues', $link->url); + $this->assertSame('https://hypervel.org/docs/queues#connections-vs-queues', $link->agentUrl); + } + + public function testUsesArbitraryUrlsForBothPeopleAndAgents(): void + { + $link = Link::to('PHP timezone list', 'https://www.php.net/manual/en/timezones.php'); + + $this->assertSame('PHP timezone list', $link->label); + $this->assertSame('https://www.php.net/manual/en/timezones.php', $link->url); + $this->assertSame('https://www.php.net/manual/en/timezones.php', $link->agentUrl); + } + + public function testReplacesTokensAcrossEveryLinkField(): void + { + $link = Link::to('{service} documentation', 'https://example.com/{service}') + ->replace(['{service}' => 'queues']); + + $this->assertEquals(Link::to('queues documentation', 'https://example.com/queues'), $link); + } + + public function testAttachesLinksToDiagnosticResults(): void + { + $link = Link::docs('queues'); + $result = DiagnosticResult::warn('Queue warning')->link($link); + + $this->assertSame([$link], $result->links); + } + + public function testAttachesLinksFluentlyToMessageDefinitions(): void + { + $link = Link::docs('queues'); + $message = Message::make('Queue warning')->link($link); + + $this->assertSame([$link], $message->links); + } +} diff --git a/tests/Doctor/Feature/Support/ConfiguredTest.php b/tests/Doctor/Feature/Support/ConfiguredTest.php new file mode 100644 index 0000000000..5b3f5f2efd --- /dev/null +++ b/tests/Doctor/Feature/Support/ConfiguredTest.php @@ -0,0 +1,68 @@ + 'secret']); + + $this->assertSame('secret', Configured::string('services.example.key')); + } + + public function testFallsBackToTheDefaultForBlankOrNonStringValues(): void + { + config([ + 'services.example.empty' => '', + 'services.example.array' => ['nested'], + ]); + + $this->assertNull(Configured::string('services.example.empty')); + $this->assertNull(Configured::string('services.example.array')); + $this->assertSame('default', Configured::string('services.example.absent', 'default')); + } + + public function testReportsConfigurationKeysWithoutAUsableValueAsMissing(): void + { + config([ + 'services.example.null' => null, + 'services.example.blank' => ' ', + 'services.example.empty-array' => [], + ]); + + $this->assertSame([ + 'services.example.null', + 'services.example.blank', + 'services.example.empty-array', + 'services.example.absent', + ], Configured::missing([ + 'services.example.null', + 'services.example.blank', + 'services.example.empty-array', + 'services.example.absent', + ])); + } + + public function testDoesNotReportKeysHoldingUsableValuesAsMissing(): void + { + config([ + 'services.example.string' => 'value', + 'services.example.false' => false, + 'services.example.zero' => 0, + 'services.example.array' => ['nested'], + ]); + + $this->assertSame([], Configured::missing([ + 'services.example.string', + 'services.example.false', + 'services.example.zero', + 'services.example.array', + ])); + } +} diff --git a/tests/Doctor/Feature/Support/DiagnosticSelectionTest.php b/tests/Doctor/Feature/Support/DiagnosticSelectionTest.php new file mode 100644 index 0000000000..f508437af2 --- /dev/null +++ b/tests/Doctor/Feature/Support/DiagnosticSelectionTest.php @@ -0,0 +1,96 @@ +app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + ]); + + $report = $doctor->only('database')->run(); + + $this->assertCount(1, $report->diagnostics()); + $this->assertSame(DatabaseDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + } + + public function testFiltersDiagnosticsByClassNameAndExcludesGroups(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + ]); + + $report = $doctor + ->only('PassingDiagnostic,DatabaseDiagnostic') + ->except('database') + ->run(); + + $this->assertCount(1, $report->diagnostics()); + $this->assertSame(PassingDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + } + + public function testFiltersDiagnosticsByResolvedPackage(): void + { + $diagnostic = new PackagedDiagnostic; + + $this->assertTrue(DiagnosticSelection::make(only: ['vendor/package'])->matches($diagnostic)); + $this->assertFalse(DiagnosticSelection::make(except: ['vendor/package'])->matches($diagnostic)); + } + + public function testFiltersDiagnosticsByWildcardPackageSelectors(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + PackagedDiagnostic::class, + HypervelPackageDiagnostic::class, + ]); + + $report = $doctor->only('hypervel/*')->run(); + + $this->assertCount(2, $report->diagnostics()); + $this->assertSame(PassingDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + $this->assertSame(HypervelPackageDiagnostic::class, $report->diagnostics()[1]->diagnostic::class); + } + + public function testFiltersBundledDoctorDiagnosticsByPackageSelector(): void + { + $this->assertTrue(DiagnosticSelection::make(only: ['hypervel/components'])->matches(new ApplicationKeyIsSet)); + $this->assertTrue(DiagnosticSelection::make(only: ['hypervel/*'])->matches(new ApplicationKeyIsSet)); + $this->assertFalse(DiagnosticSelection::make(except: ['hypervel/components'])->matches(new ApplicationKeyIsSet)); + } + + public function testNarrowsDiagnosticsWithAdditionalOnlyConstraints(): void + { + $selection = DiagnosticSelection::make(only: ['security']) + ->constrain(only: ['EnvironmentFileIsGitIgnored']); + + $this->assertTrue($selection->matches(new EnvironmentFileIsGitIgnored)); + $this->assertFalse($selection->matches(new ApplicationKeyIsSet)); + } + + public function testStacksAdditionalExceptSelectors(): void + { + $selection = DiagnosticSelection::make(except: ['security']) + ->constrain(except: ['environment']); + + $this->assertFalse($selection->matches(new EnvironmentFileIsGitIgnored)); + $this->assertFalse($selection->matches(new ApplicationKeyIsSet)); + $this->assertTrue($selection->matches(new DatabaseDiagnostic)); + } +} diff --git a/tests/Doctor/Feature/Support/DoctorTest.php b/tests/Doctor/Feature/Support/DoctorTest.php new file mode 100644 index 0000000000..3f1de97a83 --- /dev/null +++ b/tests/Doctor/Feature/Support/DoctorTest.php @@ -0,0 +1,665 @@ +app); + + $doctor->diagnostic(PassingDiagnostic::class); + + $this->assertSame([PassingDiagnostic::class], $doctor->registered()); + $this->assertTrue($doctor->hasDiagnostics()); + } + + public function testRejectsClassesThatAreNotDiagnostics(): void + { + $this->expectException(InvalidArgumentException::class); + (new Doctor($this->app))->diagnostic(stdClass::class); + } + + public function testResolvesDiagnosticSourcesFromTheClassFile(): void + { + $this->assertSame('hypervel/components', DiagnosticSource::resolve(PassingDiagnostic::class)->package); + $this->assertSame('symfony/console', DiagnosticSource::resolve(Command::class)->package); + $this->assertSame('tests/Doctor/Fixtures/Diagnostics/PassingDiagnostic.php', DiagnosticSource::resolve(PassingDiagnostic::class)->relativeFile); + $this->assertTrue(DiagnosticSource::resolve(PassingDiagnostic::class)->application); + $this->assertFalse(DiagnosticSource::resolve(ApplicationKeyIsSet::class)->application); + $this->assertFalse(DiagnosticSource::resolve(Command::class)->application); + $this->assertSame('hypervel/components', DiagnosticSource::for(new PassingDiagnostic)->label()); + $this->assertSame('hypervel/components', (new PassingDiagnostic)->package()); + $this->assertTrue(DiagnosticSource::for(new PassingDiagnostic)->application); + $this->assertSame('vendor/package', DiagnosticSource::for(new PackagedDiagnostic)->label()); + $this->assertFalse(DiagnosticSource::for(new PackagedDiagnostic)->application); + } + + public function testRunsRegisteredDiagnostics(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + WarningDiagnostic::class, + ]); + + $report = $doctor->only('testing')->run(); + + $this->assertCount(2, $report->diagnostics()); + $this->assertTrue($report->hasWarnings()); + $this->assertFalse($report->hasFailures()); + $this->assertSame('Re-run this diagnostic after addressing the warning.', $report->diagnostics()[1]->result->remediation); + } + + public function testRunsDiagnosticsInFirstSeenGroupOrder(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + WarningDiagnostic::class, + ]); + + $groups = $doctor->selectedByGroup(); + $expected = [ + PassingDiagnostic::class, + WarningDiagnostic::class, + DatabaseDiagnostic::class, + ]; + + $this->assertSame(['testing', 'database'], array_keys($groups)); + $this->assertSame($expected, array_map( + static fn (Diagnostic $diagnostic): string => $diagnostic::class, + [...$groups['testing'], ...$groups['database']], + )); + $this->assertSame($expected, array_map( + static fn (DiagnosticOutcome $outcome): string => $outcome->diagnostic::class, + $doctor->run()->diagnostics(), + )); + } + + public function testDoesNotTreatNoticesAsWarnings(): void + { + $doctor = (new Doctor($this->app))->diagnostic(NoticeDiagnostic::class); + + $report = $doctor->run(); + + $this->assertSame('notice', $report->diagnostics()[0]->result->status->value); + $this->assertFalse($report->hasWarnings()); + $this->assertFalse($report->hasFailures()); + } + + public function testDefaultsDiagnosticMetadataFromTheClassName(): void + { + $diagnostic = new DefaultMetadataDiagnostic; + + $this->assertSame('Default metadata diagnostic', $diagnostic->name); + $this->assertSame('default-metadata-diagnostic', $diagnostic->group); + } + + public function testRunsDiagnosticsInRegistrationOrder(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + PackagedDiagnostic::class, + ]); + + $report = $doctor->run(); + + $this->assertCount(2, $report->diagnostics()); + $this->assertSame(PassingDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + $this->assertSame(PackagedDiagnostic::class, $report->diagnostics()[1]->diagnostic::class); + } + + public function testConvertsDiagnosticExceptionsIntoErrorResults(): void + { + $doctor = (new Doctor($this->app))->diagnostic(ThrowingDiagnostic::class); + + $report = $doctor->run(); + $outcome = $report->diagnostics()[0]; + + $this->assertSame(ThrowingDiagnostic::class, $outcome->diagnostic::class); + $this->assertSame('error', $outcome->result->status->value); + $this->assertSame('The diagnostic exploded.', $outcome->result->summary); + $this->assertTrue($report->hasFailures()); + } + + public function testRunsAFixSuppliedByTheDiagnostic(): void + { + $doctor = (new Doctor($this->app))->diagnostic(FixableDiagnostic::class); + + $outcome = $doctor->run()->diagnostics()[0]; + + $this->assertInstanceOf(Fixable::class, $outcome->diagnostic); + $this->assertTrue($outcome->fixable()); + $this->assertSame('Fix the testing diagnostic?', $outcome->result->confirmation); + $this->assertSame('Apply the testing diagnostic fix.', $outcome->result->remediation); + + $fix = $doctor->fix($outcome); + + $this->assertSame('pass', $fix->result->status->value); + $this->assertSame('The diagnostic was fixed.', $fix->result->summary); + } + + public function testDeterminesWhetherAResultIsFixableInAnEnvironmentMode(): void + { + $this->assertFalse(DiagnosticResult::fail('Unmarked')->fixableIn(EnvironmentMode::Local)); + $this->assertTrue(DiagnosticResult::fail('Unscoped')->fixable()->fixableIn(EnvironmentMode::Local)); + $this->assertTrue(DiagnosticResult::fail('Unscoped')->fixable()->fixableIn(EnvironmentMode::Production)); + $this->assertTrue(DiagnosticResult::fail('Local')->fixable(EnvironmentMode::Local)->fixableIn(EnvironmentMode::Local)); + $this->assertFalse(DiagnosticResult::fail('Local')->fixable(EnvironmentMode::Local)->fixableIn(EnvironmentMode::Production)); + } + + public function testResolvesLocalOnlyFixabilityAgainstTheCurrentEnvironmentMode(): void + { + $outcome = new DiagnosticOutcome( + new LocalOnlyFixableDiagnostic, + (new LocalOnlyFixableDiagnostic)->check(), + ); + + $this->app->detectEnvironment(fn (): string => 'local'); + + $this->assertTrue($outcome->fixable()); + + $this->app->detectEnvironment(fn (): string => 'production'); + + $this->assertFalse($outcome->fixable()); + + $this->app->detectEnvironment(fn (): string => 'preview'); + + $this->assertFalse($outcome->fixable()); + } + + public function testSkipsLocalOnlyFixesOutsideLocalMode(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + + $offered = false; + $report = (new Doctor($this->app)) + ->diagnostic(LocalOnlyFixableDiagnostic::class) + ->fixUsing(function (DiagnosticOutcome $outcome) use (&$offered): bool { + $offered = true; + + return true; + }) + ->run(); + + $this->assertSame([], $report->fixes()); + $this->assertTrue($report->hasFailures()); + $this->assertFalse($offered); + } + + public function testAppliesLocalOnlyFixesInLocalMode(): void + { + $this->app->detectEnvironment(fn (): string => 'local'); + + $report = (new Doctor($this->app)) + ->diagnostic(LocalOnlyFixableDiagnostic::class) + ->fixUsing(fn (DiagnosticOutcome $outcome): bool => true) + ->run(); + + $this->assertCount(1, $report->fixes()); + $this->assertSame('The local diagnostic was fixed.', $report->fixes()[0]->result->summary); + } + + public function testRejectsDirectFixesForOutcomesBlockedInTheCurrentEnvironment(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('is not fixable'); + $this->app->detectEnvironment(fn (): string => 'production'); + + (new Doctor($this->app))->fix(new DiagnosticOutcome( + new LocalOnlyFixableDiagnostic, + (new LocalOnlyFixableDiagnostic)->check(), + )); + } + + public function testKeepsUnscopedOutcomesFixableInProductionMode(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + + $outcome = (new Doctor($this->app)) + ->diagnostic(FixableDiagnostic::class) + ->run() + ->diagnostics()[0]; + + $this->assertTrue($outcome->fixable()); + } + + public function testAppliesAFixWithTheOptionChosenByTheCallback(): void + { + $doctor = (new Doctor($this->app))->diagnostic(OptionFixableDiagnostic::class); + + $report = $doctor + ->fixUsing(fn (DiagnosticOutcome $outcome): string => 'a') + ->run(); + + $this->assertCount(1, $report->fixes()); + $this->assertSame('The option diagnostic was fixed with [a].', $report->fixes()[0]->result->summary); + $this->assertSame('a', config('doctor-testing.option-fixed')); + $this->assertFalse($report->hasFailures()); + } + + public function testSkipsOptionFixesDeclinedByTheCallback(): void + { + $doctor = (new Doctor($this->app))->diagnostic(OptionFixableDiagnostic::class); + + $report = $doctor + ->fixUsing(fn (DiagnosticOutcome $outcome): bool => false) + ->run(); + + $this->assertSame([], $report->fixes()); + $this->assertTrue($report->hasFailures()); + $this->assertNull(config('doctor-testing.option-fixed')); + } + + public function testRejectsPlainApprovalForOutcomesThatRequireAFixOption(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('requires a fix option'); + (new Doctor($this->app)) + ->diagnostic(OptionFixableDiagnostic::class) + ->fixUsing(fn (DiagnosticOutcome $outcome): bool => true) + ->run(); + } + + public function testRejectsUndeclaredFixOptionValues(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('is not declared'); + (new Doctor($this->app))->fix(new DiagnosticOutcome( + new OptionFixableDiagnostic, + (new OptionFixableDiagnostic)->check(), + ), 'nope'); + } + + public function testRejectsFixOptionsForOutcomesThatDoNotDeclareThem(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('does not accept a fix option'); + (new Doctor($this->app))->fix(new DiagnosticOutcome( + new FixableDiagnostic, + (new FixableDiagnostic)->check(), + ), 'a'); + } + + public function testDeterminesWhetherAFixRequiresAnOption(): void + { + $plain = new DiagnosticOutcome(new FixableDiagnostic, (new FixableDiagnostic)->check()); + $options = new DiagnosticOutcome(new OptionFixableDiagnostic, (new OptionFixableDiagnostic)->check()); + $scoped = new DiagnosticOutcome( + new OptionFixableDiagnostic, + DiagnosticResult::fail('The option diagnostic failed.') + ->fixable(EnvironmentMode::Local) + ->fixOptions(['a' => 'Option A']), + ); + + $this->assertFalse($plain->fixRequiresOption()); + $this->assertTrue($options->fixRequiresOption()); + + $this->app->detectEnvironment(fn (): string => 'production'); + + $this->assertFalse($scoped->fixRequiresOption()); + } + + public function testTurnsFixExceptionsIntoHelpfulErrorResults(): void + { + $fix = (new Doctor($this->app))->fix(new DiagnosticOutcome( + new ThrowingFixDiagnostic, + DiagnosticResult::fail('The diagnostic failed.')->fixable(), + )); + + $this->assertSame('error', $fix->result->status->value); + $this->assertSame('Failed to fix Testing diagnostic fix throws: permission denied', $fix->result->summary); + } + + public function testRejectsFixesForDiagnosticsThatAreNotFixable(): void + { + $this->expectException(LogicException::class); + (new Doctor($this->app))->fix(new DiagnosticOutcome( + new PassingDiagnostic, + DiagnosticResult::fail('The diagnostic failed.'), + )); + } + + public function testRejectsFixesForOutcomesThatAreNotExplicitlyFixable(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('is not fixable'); + (new Doctor($this->app))->fix(new DiagnosticOutcome( + new FixableDiagnostic, + DiagnosticResult::fail('The diagnostic failed.'), + )); + } + + public function testRejectsFixesForErrorOutcomesEvenWhenMarkedFixable(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('is not fixable'); + (new Doctor($this->app))->fix(new DiagnosticOutcome( + new FixableDiagnostic, + DiagnosticResult::error('The diagnostic errored.')->fixable(), + )); + } + + public function testDoesNotOfferAnAutomaticFixAfterAFixableDiagnosticThrows(): void + { + $doctor = (new Doctor($this->app))->diagnostic(ThrowingFixableDiagnostic::class); + $offered = false; + + $report = $doctor + ->fixUsing(function (DiagnosticOutcome $outcome) use (&$offered): bool { + $offered = true; + + return true; + }) + ->run(); + + $this->assertSame('error', $report->diagnostics()[0]->result->status->value); + $this->assertFalse($report->diagnostics()[0]->fixable()); + $this->assertSame([], $report->fixes()); + $this->assertFalse($offered); + } + + public function testAppliesConfiguredSelectorsToProgrammaticRuns(): void + { + config(['doctor.only' => ['database']]); + + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + ]); + + $report = $doctor->run(); + + $this->assertCount(1, $report->diagnostics()); + $this->assertSame(DatabaseDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + } + + public function testConstrainsRunsWithFluentOnlyAndExceptSelectors(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + PackagedDiagnostic::class, + ]); + + $report = $doctor + ->only('testing') + ->except('vendor/package') + ->run(); + + $this->assertCount(1, $report->diagnostics()); + $this->assertSame(PassingDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + } + + public function testIntersectsRepeatedFluentOnlyConstraints(): void + { + config(['doctor.only' => ['testing']]); + + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + PackagedDiagnostic::class, + ]); + + $report = $doctor + ->only(PassingDiagnostic::class, DatabaseDiagnostic::class) + ->run(); + + $this->assertCount(1, $report->diagnostics()); + $this->assertSame(PassingDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + } + + public function testBailsAfterTheFirstFailingDiagnostic(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + WarningDiagnostic::class, + FixableDiagnostic::class, + PassingDiagnostic::class, + DatabaseDiagnostic::class, + ]); + + $report = $doctor->bail()->run(); + + $this->assertSame([ + WarningDiagnostic::class, + FixableDiagnostic::class, + ], array_map( + static fn (DiagnosticOutcome $outcome): string => $outcome->diagnostic::class, + $report->diagnostics(), + )); + } + + public function testTreatsDiagnosticErrorsAsFailuresWhenBailing(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + ThrowingDiagnostic::class, + PassingDiagnostic::class, + ]); + + $report = $doctor->bail()->run(); + + $this->assertCount(1, $report->diagnostics()); + $this->assertSame(ThrowingDiagnostic::class, $report->diagnostics()[0]->diagnostic::class); + $this->assertSame('error', $report->diagnostics()[0]->result->status->value); + } + + public function testAppliesApprovedFixesAndRerunsTheDiagnostics(): void + { + config(['doctor-testing.shared-state-fixed' => false]); + + $doctor = (new Doctor($this->app))->diagnostics([ + FixesSharedStateDiagnostic::class, + SharedStateWasFixedDiagnostic::class, + ]); + + $rerunning = false; + + $report = $doctor + ->fixUsing(fn (DiagnosticOutcome $outcome): bool => true) + ->beforeRerun(function () use (&$rerunning): void { + $rerunning = true; + }) + ->run(); + + $this->assertCount(1, $report->fixes()); + $this->assertSame('The shared state fix ran.', $report->fixes()[0]->result->summary); + $this->assertFalse($report->hasFailures()); + $this->assertTrue($rerunning); + } + + public function testDoesNotRerunDiagnosticsWhenFixesAreDeclined(): void + { + config(['doctor-testing.shared-state-fixed' => false]); + + $doctor = (new Doctor($this->app))->diagnostic(FixesSharedStateDiagnostic::class); + + $rerunning = false; + + $report = $doctor + ->fixUsing(fn (DiagnosticOutcome $outcome): bool => false) + ->beforeRerun(function () use (&$rerunning): void { + $rerunning = true; + }) + ->run(); + + $this->assertSame([], $report->fixes()); + $this->assertTrue($report->hasFailures()); + $this->assertFalse($rerunning); + } + + public function testOwnsDiagnosticExecutionWhileAllowingProgressToBeObserved(): void + { + $doctor = (new Doctor($this->app))->diagnostics([ + PassingDiagnostic::class, + DatabaseDiagnostic::class, + ThrowingDiagnostic::class, + ]); + + $observed = []; + + $report = $doctor + ->observeUsing(function (string $group, Closure $next) use (&$observed): void { + $observed[] = 'group:' . $group; + + $next( + before: function (Diagnostic $diagnostic) use (&$observed): void { + $observed[] = 'checking:' . class_basename($diagnostic); + }, + after: function (DiagnosticOutcome $outcome) use (&$observed): void { + $observed[] = 'result:' . $outcome->result->status->value; + }, + ); + }) + ->run(); + + $this->assertCount(3, $report->diagnostics()); + $this->assertSame('error', $report->diagnostics()[1]->result->status->value); + $this->assertSame([ + 'group:testing', + 'checking:PassingDiagnostic', + 'result:pass', + 'checking:ThrowingDiagnostic', + 'result:error', + 'group:database', + 'checking:DatabaseDiagnostic', + 'result:pass', + ], $observed); + } + + public function testRequiresProgressObserversToContinueRunnerOwnedExecution(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('must invoke its continuation'); + (new Doctor($this->app)) + ->diagnostic(PassingDiagnostic::class) + ->observeUsing(function (string $group, Closure $next): void { + // Intentionally do not continue the runner-owned execution. + }) + ->run(); + } + + public function testAppendsFixesToAnExistingDiagnosticReport(): void + { + $diagnostic = new PassingDiagnostic; + $first = new DiagnosticFixOutcome($diagnostic, FixResult::pass('The first fix passed.')); + $second = new DiagnosticFixOutcome($diagnostic, FixResult::pass('The second fix passed.')); + + $report = (new DiagnosticReport)->withFixes([$first])->withFixes([$second]); + + $this->assertSame([$first, $second], $report->fixes()); + } + + public function testDiagnosticInstanceStateIsFreshForPostFixAndLaterRuns(): void + { + config(['doctor-testing.shared-state-fixed' => false]); + $doctor = (new Doctor($this->app))->diagnostic(MemoizingDoctorDiagnostic::class); + + $report = $doctor->fixUsing(fn (): bool => true)->run(); + + $this->assertFalse($report->hasFailures()); + $this->assertNotSame($report->fixes()[0]->diagnostic, $report->diagnostics()[0]->diagnostic); + + config(['doctor-testing.shared-state-fixed' => false]); + + $this->assertTrue($doctor->run()->hasFailures()); + } + + #[DataProvider('canceledOperations')] + public function testCancellationPropagatesUnchanged(string $operation): void + { + $cancellation = new CanceledException('Doctor operation canceled'); + $diagnostic = new class($cancellation) extends Diagnostic implements Fixable { + /** + * Capture the cancellation raised by diagnostic work. + */ + public function __construct(private CanceledException $cancellation) + { + parent::__construct(); + } + + /** + * Simulate a canceled check. + */ + public function check(): DiagnosticResult + { + throw $this->cancellation; + } + + /** + * Simulate a canceled repair. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + throw $this->cancellation; + } + }; + $doctor = new Doctor($this->app); + + try { + match ($operation) { + 'check' => $doctor->check($diagnostic), + 'fix' => $doctor->fix(new DiagnosticOutcome($diagnostic, DiagnosticResult::fail('Repair required')->fixable())), + }; + $this->fail('The operation must propagate cancellation.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + } + + /** + * Provide the runner's diagnostic entry points. + */ + public static function canceledOperations(): array + { + return [['check'], ['fix']]; + } +} + +class MemoizingDoctorDiagnostic extends FixesSharedStateDiagnostic +{ + private ?DiagnosticResult $result = null; + + /** + * Retain a result for this diagnostic invocation. + */ + public function check(): DiagnosticResult + { + return $this->result ??= parent::check(); + } +} diff --git a/tests/Doctor/Feature/Support/EnvironmentModeTest.php b/tests/Doctor/Feature/Support/EnvironmentModeTest.php new file mode 100644 index 0000000000..7dabc1b4c8 --- /dev/null +++ b/tests/Doctor/Feature/Support/EnvironmentModeTest.php @@ -0,0 +1,58 @@ + [ + 'local' => ['dev'], + 'production' => ['staging'], + ]]); + + $this->assertSame(EnvironmentMode::Local, EnvironmentMode::fromHypervelEnvironment('dev')); + $this->assertSame(EnvironmentMode::Production, EnvironmentMode::fromHypervelEnvironment('staging')); + } + + public function testTreatsUnmappedEnvironmentsAsProduction(): void + { + $this->assertSame(EnvironmentMode::Production, EnvironmentMode::fromHypervelEnvironment('preview')); + } + + public function testRejectsInvalidConfiguredEnvironmentModes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('Invalid Doctor environment mode [lcoal] configured.'); + config(['doctor.environments' => ['lcoal' => ['local']]]); + + EnvironmentMode::fromHypervelEnvironment('local'); + } + + public function testRejectsEnvironmentNamesThatAreNotConfiguredAsArrays(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('Hypervel environment names for Doctor mode [local] must be an array, [string] given.'); + config(['doctor.environments' => ['local' => 'local']]); + + EnvironmentMode::fromHypervelEnvironment('local'); + } + + public function testRejectsEnvironmentNamesAssignedToMultipleModes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('Hypervel environment [shared] is configured for multiple Doctor modes.'); + config(['doctor.environments' => [ + 'local' => ['shared'], + 'production' => ['shared'], + ]]); + + EnvironmentMode::fromHypervelEnvironment('shared'); + } +} diff --git a/tests/Doctor/Feature/Support/InstalledPackagesTest.php b/tests/Doctor/Feature/Support/InstalledPackagesTest.php new file mode 100644 index 0000000000..4dd0f0bfdd --- /dev/null +++ b/tests/Doctor/Feature/Support/InstalledPackagesTest.php @@ -0,0 +1,136 @@ +path = ParallelTesting::tempDir('DoctorInstalledPackages'); + File::deleteDirectory($this->path); + File::makeDirectory($this->path . '/composer', 0775, true); + $this->packages = new class($this->path) extends InstalledPackages { + /** + * Use fixture metadata for this snapshot. + */ + public function __construct(private string $path) + { + } + + /** + * Resolve the fixture vendor path. + */ + protected function vendorPath(): string + { + return $this->path; + } + }; + } + + /** + * Remove temporary installed metadata. + */ + protected function tearDown(): void + { + try { + File::deleteDirectory($this->path); + } finally { + parent::tearDown(); + } + } + + #[DataProvider('installationModes')] + public function testOnlyIncludesRootDevelopmentRequirementsForDevInstalls(?bool $dev): void + { + if ($dev !== null) { + file_put_contents($this->path . '/composer/installed.json', json_encode([ + 'dev' => $dev, + 'packages' => [[ + 'name' => 'example/dependency', + 'require' => ['php' => '>=8.4', 'example/library' => '^1.0'], + 'require-dev' => ['ext-dependency-dev' => '*'], + ]], + ], JSON_THROW_ON_ERROR)); + } + + $snapshot = $this->snapshot([ + 'name' => 'example/app', + 'require' => ['ext-shared' => '^2.0'], + 'require-dev' => ['ext-shared' => '^2.5', 'ext-root-dev' => '*'], + ]); + + $this->assertSame($dev === true + ? ['example/app' => '^2.0', 'example/app (require-dev)' => '^2.5'] + : ['example/app' => '^2.0'], $snapshot['ext-shared']['requirements']); + $this->assertSame($dev === true, isset($snapshot['ext-root-dev'])); + $this->assertArrayNotHasKey('ext-dependency-dev', $snapshot); + $this->assertArrayNotHasKey('example/library', $snapshot); + + if ($dev !== null) { + $this->assertSame(['example/dependency' => '>=8.4'], $snapshot['php']['requirements']); + } + } + + /** + * Provide installed development, production, and unavailable metadata. + */ + public static function installationModes(): array + { + return [[true], [false], [null]]; + } + + public function testResolvesRootAndInstalledSelfVersionsAndPreservesProviderRanges(): void + { + file_put_contents($this->path . '/composer/installed.json', json_encode(['packages' => [[ + 'name' => 'example/provider', + 'version' => 'v2.4.0', + 'version_normalized' => '2.4.0.0', + 'provide' => ['ext-provided' => '^2.0'], + 'replace' => ['ext-replaced' => 'self.version'], + ]]], JSON_THROW_ON_ERROR)); + file_put_contents($this->path . '/composer/installed.php', " ['version' => '3.0.0.0']];"); + + $snapshot = $this->snapshot([ + 'name' => 'example/app', + 'provide' => ['ext-root' => 'self.version'], + ]); + + $this->assertSame(['example/app' => '3.0.0.0'], $snapshot['ext-root']['providers']); + $this->assertSame(['example/provider' => '^2.0'], $snapshot['ext-provided']['providers']); + $this->assertSame(['example/provider' => '2.4.0.0'], $snapshot['ext-replaced']['providers']); + } + + public function testDoesNotInventAnUnknownRootSelfVersion(): void + { + $this->assertSame([], $this->snapshot(['provide' => ['ext-root' => 'self.version']])); + } + + /** + * Build a snapshot using the diagnostic's supplied root manifest. + */ + private function snapshot(array $root): array + { + $composer = m::mock(ComposerJson::class); + $composer->allows('contents')->andReturn($root); + + return $this->packages->platformRequirements($composer); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/DatabaseDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/DatabaseDiagnostic.php new file mode 100644 index 0000000000..aa5203f493 --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/DatabaseDiagnostic.php @@ -0,0 +1,23 @@ +fixable() + ->confirmUsing('Fix the testing diagnostic?') + ->suggest('Apply the testing diagnostic fix.'); + } + + /** + * Apply the diagnostic fix. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + return FixResult::pass('The diagnostic was fixed.'); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/FixesSharedStateDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/FixesSharedStateDiagnostic.php new file mode 100644 index 0000000000..8dcdc0cf1d --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/FixesSharedStateDiagnostic.php @@ -0,0 +1,42 @@ +fixable() + ->suggest('Apply the shared state fix.') + ->confirmUsing('Fix the shared state?'); + } + + /** + * Apply the diagnostic fix. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + config(['doctor-testing.shared-state-fixed' => true]); + + return FixResult::pass('The shared state fix ran.'); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/HypervelPackageDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/HypervelPackageDiagnostic.php new file mode 100644 index 0000000000..2ff7d70bc6 --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/HypervelPackageDiagnostic.php @@ -0,0 +1,31 @@ + + */ + protected function messages(): array + { + return [ + 'warning' => Message::make( + summary: 'The linked diagnostic warned.', + remediation: 'Follow the linked documentation.', + ) + ->link(Link::docs('queues', 'connections-vs-queues')) + ->link(Link::to('Related {topic} guide', 'https://example.com/{topic}')), + ]; + } + + /** + * Run the diagnostic. + */ + public function check(): DiagnosticResult + { + return $this->warn('warning', ['topic' => 'links']) + ->withDetails('Detailed link context.'); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/LocalOnlyFixableDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/LocalOnlyFixableDiagnostic.php new file mode 100644 index 0000000000..c1307bbd4c --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/LocalOnlyFixableDiagnostic.php @@ -0,0 +1,37 @@ +fixable(EnvironmentMode::Local) + ->confirmUsing('Fix the local testing diagnostic?') + ->suggest('Apply the local testing diagnostic fix.'); + } + + /** + * Apply the diagnostic fix. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + return FixResult::pass('The local diagnostic was fixed.'); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/NoticeDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/NoticeDiagnostic.php new file mode 100644 index 0000000000..5ca587767f --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/NoticeDiagnostic.php @@ -0,0 +1,24 @@ +suggest('This notice fixture simulates troubleshooting context.'); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/OptionFixableDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/OptionFixableDiagnostic.php new file mode 100644 index 0000000000..3580136282 --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/OptionFixableDiagnostic.php @@ -0,0 +1,43 @@ +fixable() + ->fixOptions(config()->array('doctor-testing.options', ['a' => 'Option A', 'b' => 'Option B'])) + ->confirmUsing('Which option should fix the diagnostic?') + ->suggest('Apply one of the diagnostic fix options.'); + } + + /** + * Apply the diagnostic fix. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + config(['doctor-testing.option-fixed' => $option]); + + return FixResult::pass(sprintf('The option diagnostic was fixed with [%s].', $option)); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/PackagedDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/PackagedDiagnostic.php new file mode 100644 index 0000000000..c26f82ee93 --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/PackagedDiagnostic.php @@ -0,0 +1,31 @@ +suggest('This packaged notice fixture simulates troubleshooting context.'); + } + + /** + * Get the diagnostic's package name. + */ + public function package(): ?string + { + return 'vendor/package'; + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/PassingDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/PassingDiagnostic.php new file mode 100644 index 0000000000..f0e126c4e0 --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/PassingDiagnostic.php @@ -0,0 +1,23 @@ +fixable() + ->confirmUsing('Fix the testing diagnostic?') + ->suggest('Apply the testing diagnostic fix.'); + } + + /** + * Apply the diagnostic fix. + */ + public function fix(DiagnosticResult $result, ?string $option = null): FixResult + { + throw new RuntimeException('permission denied'); + } +} diff --git a/tests/Doctor/Fixtures/Diagnostics/ThrowingFixableDiagnostic.php b/tests/Doctor/Fixtures/Diagnostics/ThrowingFixableDiagnostic.php new file mode 100644 index 0000000000..83c3995682 --- /dev/null +++ b/tests/Doctor/Fixtures/Diagnostics/ThrowingFixableDiagnostic.php @@ -0,0 +1,34 @@ +withDetails('This warning fixture simulates a non-fixable issue.') + ->suggest('Re-run this diagnostic after addressing the warning.'); + } +} diff --git a/tests/Doctor/TestCase.php b/tests/Doctor/TestCase.php new file mode 100644 index 0000000000..6b7f463172 --- /dev/null +++ b/tests/Doctor/TestCase.php @@ -0,0 +1,66 @@ + + */ + private array $agentEnvironment = []; + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->forgetAgentEnvironment(); + } + + /** + * Restore process environment values after the test. + */ + protected function tearDown(): void + { + try { + parent::tearDown(); + } finally { + foreach ($this->agentEnvironment as $variable => $value) { + $value === false ? putenv($variable) : putenv("{$variable}={$value}"); + } + } + } + + /** + * Get the package providers. + */ + protected function getPackageProviders(Application $app): array + { + return [ + DoctorServiceProvider::class, + ]; + } + + /** + * Clear agent environment variables so output format detection stays + * deterministic when the suite itself runs inside an AI agent. + */ + protected function forgetAgentEnvironment(): void + { + foreach (['AI_AGENT', ...array_keys(AgentDetector::AGENT_ENV_VARS)] as $variable) { + $this->agentEnvironment[$variable] = getenv($variable); + putenv($variable); + } + } +} diff --git a/tests/Foundation/ApplicationRunningInConsoleTest.php b/tests/Foundation/ApplicationRunningInConsoleTest.php index fd8b7eedec..f188d69a2d 100644 --- a/tests/Foundation/ApplicationRunningInConsoleTest.php +++ b/tests/Foundation/ApplicationRunningInConsoleTest.php @@ -321,28 +321,29 @@ public function testDetectEnvironmentUsesArgvWhenInConsole(): void $this->assertSame('staging', $app->environment()); } - public function testDetectEnvironmentIgnoresArgvWhenNotInConsole(): void + public function testDetectEnvironmentUsesStartupArgumentsWhenNotInConsole(): void { - $_SERVER['argv'] = ['artisan', '--env=staging']; + $_SERVER['argv'] = ['artisan', 'serve', '--env=staging']; $app = new Application; $app->setRunningInConsole(false); - // When not in console, argv is not passed — callback determines environment $result = $app->detectEnvironment(fn () => 'production'); - $this->assertSame('production', $result); + $this->assertSame('staging', $result); + $this->assertFalse($app->runningInConsole()); } - public function testDetectEnvironmentIgnoresArgvWhenHttpSemanticsAreSetViaEnvVar(): void + public function testDetectEnvironmentUsesStartupArgumentsWhenHttpSemanticsAreSetViaEnvVar(): void { - $_SERVER['argv'] = ['artisan', '--env=staging']; + $_SERVER['argv'] = ['artisan', 'serve', '--env=staging']; putenv('APP_RUNNING_IN_CONSOLE=false'); $app = new Application; $result = $app->detectEnvironment(fn () => 'production'); - $this->assertSame('production', $result); + $this->assertSame('staging', $result); + $this->assertFalse($app->runningInConsole()); } } diff --git a/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php b/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php index 3f7c512683..397601fa55 100644 --- a/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php +++ b/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php @@ -12,6 +12,7 @@ use Hypervel\Support\Env; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class LoadEnvironmentVariablesTest extends TestCase { @@ -112,7 +113,8 @@ public function testLoadsDefaultEnvFile(): void $this->assertSame('default_value', env('TEST_KEY')); } - public function testSkipsWhenConfigIsCached(): void + #[DataProvider('cachedEnvironmentSelections')] + public function testSkipsLoadingButSelectsEnvironmentWhenConfigIsCached(?string $environment, ?string $option, string $file): void { $tempDir = ParallelTesting::tempDir('LoadEnvVarsTest'); $filesystem = new Filesystem; @@ -124,6 +126,17 @@ public function testSkipsWhenConfigIsCached(): void try { $app = new Application($tempDir); + $app->setRunningInConsole(true); + $_SERVER['argv'] = ['artisan']; + + if ($environment !== null) { + $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $environment; + putenv('APP_ENV=' . $environment); + } + + if ($option !== null) { + $_SERVER['argv'][] = '--env=' . $option; + } // Create a fake cached config file so configurationIsCached() returns true. $cachePath = $app->getCachedConfigPath(); @@ -140,11 +153,24 @@ public function testSkipsWhenConfigIsCached(): void // Env vars should NOT be loaded since config is cached. $this->assertNull(env('APP_NAME')); $this->assertNull(env('TEST_KEY')); + $this->assertSame($tempDir . '/' . $file, $app->environmentFilePath()); } finally { $filesystem->deleteDirectory($tempDir); } } + /** + * Provide environment-file selection with cached configuration. + */ + public static function cachedEnvironmentSelections(): array + { + return [ + 'default' => [null, null, '.env'], + 'external environment' => ['testing', null, '.env.testing'], + 'explicit environment wins' => ['production', 'testing', '.env.testing'], + ]; + } + public function testLoadsPerEnvironmentFile(): void { // Set APP_ENV so the bootstrapper finds .env.testing. @@ -203,19 +229,33 @@ public function testNoErrorWhenEnvFileMissing(): void $this->assertNull(env('APP_NAME')); } - public function testEnvCliOptionOverridesEnvironmentFile(): void + #[DataProvider('commandModes')] + public function testEnvCliOptionOverridesEnvironmentFile(string $command, bool $runningInConsole): void { - // Simulate `php artisan --env=testing`. - $_SERVER['argv'] = ['artisan', '--env=testing']; + $_SERVER['argv'] = ['artisan', $command, '--env=testing']; $app = $this->createApp(); - $app->setRunningInConsole(true); + $app->setRunningInConsole($runningInConsole); (new LoadEnvironmentVariables)->bootstrap($app); $this->assertSame('HypervelTesting', env('APP_NAME')); $this->assertSame('testing_value', env('TEST_KEY')); $this->assertSame('.env.testing', $app->environmentFile()); + $this->assertSame('testing', $app->detectEnvironment(fn (): string => 'production')); + $this->assertSame($runningInConsole, $app->runningInConsole()); + } + + /** + * Provide console and HTTP-mode startup commands. + */ + public static function commandModes(): array + { + return [ + 'console command' => ['about', true], + 'HTTP server' => ['serve', false], + 'watcher' => ['watch', false], + ]; } public function testMalformedEnvFileTriggersErrorHandler(): void diff --git a/tests/Foundation/Console/ConfigShowCommandTest.php b/tests/Foundation/Console/ConfigShowCommandTest.php index bb4494ed2f..4c80a58a97 100644 --- a/tests/Foundation/Console/ConfigShowCommandTest.php +++ b/tests/Foundation/Console/ConfigShowCommandTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Foundation\Console; use Hypervel\Foundation\Console\ConfigShowCommand; +use Hypervel\Support\Facades\Artisan; +use JsonException; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; class ConfigShowCommandTest extends \Hypervel\Testbench\TestCase @@ -88,4 +91,45 @@ public function testDisplayErrorIfConfigDoesNotExist(): void $this->artisan(ConfigShowCommand::class, ['config' => 'invalid']) ->assertFailed(); } + + #[DataProvider('jsonValues')] + public function testJsonPreservesTheSelectedValue(mixed $value): void + { + config()->set('test.selected', $value); + + $this->assertSame(0, Artisan::call(ConfigShowCommand::class, [ + 'config' => 'test.selected', + '--json' => true, + ])); + $this->assertSame($value, json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * Provide configuration values whose JSON types must be preserved. + */ + public static function jsonValues(): array + { + return [ + 'section' => [['nested' => ['enabled' => false, 'count' => 3, 'ratio' => 1.5, 'optional' => null]]], + 'string with console markup' => ['value'], + 'boolean' => [false], + 'null' => [null], + 'empty array' => [[]], + ]; + } + + public function testJsonFailsForMissingConfiguration(): void + { + $this->artisan(ConfigShowCommand::class, ['config' => 'invalid', '--json' => true]) + ->assertFailed(); + } + + public function testJsonReportsEncodingFailures(): void + { + config()->set('test.value', "\xB1"); + + $this->expectException(JsonException::class); + + Artisan::call(ConfigShowCommand::class, ['config' => 'test.value', '--json' => true]); + } } diff --git a/tests/Foundation/Console/RouteListCommandTest.php b/tests/Foundation/Console/RouteListCommandTest.php index 1c8153c0ab..c64133c5dd 100644 --- a/tests/Foundation/Console/RouteListCommandTest.php +++ b/tests/Foundation/Console/RouteListCommandTest.php @@ -11,11 +11,16 @@ use Hypervel\Foundation\Http\Kernel; use Hypervel\Routing\Router; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; class RouteListCommandTest extends TestCase { protected Application $consoleApp; + protected Router $router; + /** * Set up the test environment. */ @@ -29,7 +34,7 @@ protected function setUp(): void 'testing', ); - $router = new Router(new Dispatcher($hypervel)); + $router = $this->router = new Router(new Dispatcher($hypervel)); $kernel = new class($hypervel, $router) extends Kernel { protected array $middlewareGroups = [ @@ -68,6 +73,27 @@ protected function setUp(): void $this->consoleApp->addCommands([$command]); } + #[DataProvider('outputDecoration')] + public function testJsonOutputPreservesLiteralRouteNames(bool $decorated): void + { + $name = 'Route\name \literal'; + $this->router->get('/literal-name', fn (): string => 'ok')->name($name); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, $decorated); + + $this->consoleApp->call('route:list', ['--json' => true, '--path' => 'literal-name'], $output); + + $routes = json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR); + $this->assertSame($name, $routes[0]['name']); + } + + /** + * Provide terminal and redirected output modes. + */ + public static function outputDecoration(): array + { + return [[false], [true]]; + } + public function testNoMiddlewareIfNotVerbose(): void { $this->consoleApp->call('route:list'); diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index 79af3ebf93..5aec0c9250 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -7,6 +7,8 @@ use Hypervel\Broadcasting\Broadcasters\LogBroadcaster; use Hypervel\Broadcasting\Broadcasters\NullBroadcaster; use Hypervel\Broadcasting\BroadcastManager; +use Hypervel\Cache\Console\CacheTableCommand; +use Hypervel\Cache\DatabaseStore; use Hypervel\Config\Repository; use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Container\Container; @@ -89,6 +91,17 @@ public function testCacheConfigIgnoresTheLegacyScheduleCacheDriverEnvironmentVar $this->assertNull($config['schedule_store']); } + public function testDatabaseCacheDefaultsMatchTheGeneratedTables(): void + { + $config = $this->withEnvironmentValues([ + 'DB_CACHE_TABLE' => null, + 'DB_CACHE_LOCK_TABLE' => null, + ], fn (): array => $this->cacheConfig()); + + $this->assertSame(CacheTableCommand::TABLE, $config['stores']['database']['table']); + $this->assertSame(DatabaseStore::DEFAULT_LOCK_TABLE, $config['stores']['database']['lock_table']); + } + public function testShippedCacheStoresUseTheirDriverEventDefaults(): void { $this->assertNotNull($this->app->make('cache')->store('array')->getEventDispatcher()); diff --git a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php index c03bcedeba..0f99bd96e2 100644 --- a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php +++ b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php @@ -77,6 +77,43 @@ public function testMissingEnvironmentFileClearsPreviouslyLoadedValues(): void $this->assertNull(Env::get('APP_NAME')); } + public function testWorkerReloadUsesCurrentCacheFileInsteadOfPreviousBootstrapState(): void + { + $root = ParallelTesting::tempDir('ReloadDotenvAndConfigTest-cache'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($root); + $filesystem->ensureDirectoryExists($root . '/config'); + $filesystem->ensureDirectoryExists($root . '/bootstrap/cache'); + + try { + $filesystem->put($root . '/.env', "DOCTOR_WORKER_VALUE=from-file\n"); + $filesystem->put($root . '/config/worker.php', " env('DOCTOR_WORKER_VALUE', 'missing')];\n"); + $app = new Application($root); + (new LoadConfiguration)->bootstrap($app); + $this->assertFalse($app->configurationIsCached()); + + $cached = $app->make(Repository::class)->all(); + $cached['worker']['value'] = 'cached'; + $filesystem->put($app->getCachedConfigPath(), 'make(ReloadDotenvAndConfig::class); + $listener->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertNull(Env::get('DOCTOR_WORKER_VALUE')); + $this->assertSame('cached', $app->make(Repository::class)->get('worker.value')); + $this->assertTrue($app->configurationIsCached()); + + $filesystem->delete($app->getCachedConfigPath()); + $listener->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertSame('from-file', Env::get('DOCTOR_WORKER_VALUE')); + $this->assertSame('from-file', $app->make(Repository::class)->get('worker.value')); + $this->assertFalse($app->configurationIsCached()); + } finally { + $filesystem->deleteDirectory($root); + } + } + public function testReloadPreservesRepositoryIdentityAndMutationsMadeBeforeListenerResolution(): void { $app = $this->createApp(); diff --git a/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php b/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php index 437f78a54a..d074465d7d 100644 --- a/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php +++ b/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php @@ -21,7 +21,15 @@ protected function setUp(): void public function testSkipsWhenPhpredisBelowMinimum(): void { - $subject = new RequiresHashFieldExpirationTestSubject; + $subject = new class extends RequiresHashFieldExpirationTestSubject { + /** + * Reject server access when the client is already unsupported. + */ + protected function detectedServerInfo(): array + { + throw new RuntimeException('The server must not be queried with an unsupported client.'); + } + }; $subject->stubPhpredisVersion = '6.2.0'; $this->expectException(RuntimeException::class); @@ -94,6 +102,17 @@ public function testMemoizesCheckAcrossCalls(): void $this->assertSame(1, $subject->serverInfoCalls, 'Support check must be memoized after first run'); } + + public function testSkipsWhenServerVersionCannotBeDetermined(): void + { + $subject = new RequiresHashFieldExpirationTestSubject; + $subject->stubServerInfo = []; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not determine Redis/Valkey version'); + + $subject->runCheck(); + } } class RequiresHashFieldExpirationTestSubject diff --git a/tests/Integration/Encryption/KeyGenerateCommandTest.php b/tests/Integration/Encryption/KeyGenerateCommandTest.php index b885c52a98..f7bec69ad1 100644 --- a/tests/Integration/Encryption/KeyGenerateCommandTest.php +++ b/tests/Integration/Encryption/KeyGenerateCommandTest.php @@ -183,8 +183,8 @@ public function testProhibitedCommandDoesNotGenerateOrPublishAKey(): void $this->assertSame('', $config->get('app.key')); } - #[DataProvider('quotedKeyLines')] - public function testExactQuotedKeyLinesAreReplaced(string $configuredKey, string $line, string $suffix): void + #[DataProvider('environmentKeyLines')] + public function testExactKeyLinesAreReplaced(string $configuredKey, string $line, string $suffix, string $prefix = ''): void { $config = $this->app->make('config'); $config->set('app.key', $configuredKey); @@ -200,21 +200,30 @@ public function testExactQuotedKeyLinesAreReplaced(string $configuredKey, string $this->assertIsString($generatedKey); $this->assertStringStartsWith('base64:', $generatedKey); - $this->assertSame("APP_KEY={$generatedKey}{$suffix}", $contents); + $this->assertSame("{$prefix}APP_KEY={$generatedKey}{$suffix}", $contents); } /** - * Provide supported quoted environment key lines. + * Provide supported environment key lines and suffixes that dotenv ignores. * - * @return array + * @return array */ - public static function quotedKeyLines(): array + public static function environmentKeyLines(): array { return [ 'empty double quotes' => ['', 'APP_KEY=""', ''], 'empty single quotes' => ['', "APP_KEY=''", ''], 'quoted current key' => ['base64:current', 'APP_KEY="base64:current"', ''], 'CRLF quoted current key' => ['base64:current', "APP_KEY=\"base64:current\"\r\nAPP_NAME=Hypervel", "\r\nAPP_NAME=Hypervel"], + 'exported empty key' => ['', 'export APP_KEY=', '', 'export '], + 'exported quoted key' => ['base64:current', 'export APP_KEY="base64:current"', '', 'export '], + 'indented export' => ['', "\texport\tAPP_KEY=''", '', "\texport\t"], + 'trailing whitespace' => ['base64:current', "APP_KEY=base64:current \t", " \t"], + 'trailing comment' => ['base64:current', 'APP_KEY=base64:current # rotate yearly', ' # rotate yearly'], + 'quoted key with comment' => ['base64:current', 'APP_KEY="base64:current" # rotate yearly', ' # rotate yearly'], + 'adjacent comment' => ['base64:current', 'APP_KEY=base64:current#rotate', '#rotate'], + 'empty key with whitespace' => ['', 'APP_KEY= ', ' '], + 'CRLF with comment' => ['base64:current', "APP_KEY=base64:current # rotate\r\nAPP_NAME=Hypervel", " # rotate\r\nAPP_NAME=Hypervel"], ]; } @@ -243,6 +252,7 @@ public static function nonMatchingKeyLines(): array return [ 'longer prefixed key' => ['APP_KEY=base64:currentsuffix'], 'mismatched quotes' => ['APP_KEY="base64:current\''], + 'exported different key' => ['export APP_KEY=base64:other'], ]; } diff --git a/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php b/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php index caff568d74..5a79c52676 100644 --- a/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php +++ b/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php @@ -11,12 +11,15 @@ use Mockery as m; use RuntimeException; use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Process; class ConfigCacheCommandTest extends TestCase { protected array $files = [ 'bootstrap/cache/config.php', 'config/testconfig.php', + '.env', + '.env.doctor', ]; protected function setUp(): void @@ -146,6 +149,30 @@ public function testConfigCacheDoesNotOverwriteGlobalContainerInstance(): void $this->assertSame($originalInstance, Container::getInstance()); } + public function testCachedCommandForwardsExplicitEnvironmentToItsBuildProcess(): void + { + $files = new Filesystem; + $files->put($this->app->basePath('.env'), "DOCTOR_CONFIG_VALUE=default\n"); + $files->put($this->app->basePath('.env.doctor'), "DOCTOR_CONFIG_VALUE=selected\n"); + $files->put($this->app->configPath('testconfig.php'), " env('DOCTOR_CONFIG_VALUE')];\n"); + + $this->artisan('config:cache')->assertSuccessful(); + + $process = new Process( + [PHP_BINARY, $this->app->basePath('artisan'), 'config:cache', '--env=doctor'], + $this->app->basePath(), + [ + 'HYPERVEL_AUTOLOAD_PATH' => dirname(__DIR__, 4) . '/vendor/autoload.php', + 'APP_ENV' => false, + 'DOCTOR_CONFIG_VALUE' => false, + ], + ); + $process->mustRun(); + + $cached = require $this->app->getCachedConfigPath(); + $this->assertSame('selected', $cached['testconfig']['value']); + } + public function testConfigurationCacheRebuildsFromSourceWhenApplicationBootedWithExistingCachedConfig(): void { $files = new Filesystem; diff --git a/tests/Integration/Foundation/Console/RouteCacheCommandTest.php b/tests/Integration/Foundation/Console/RouteCacheCommandTest.php index 0bac7732b4..2dbdb64136 100644 --- a/tests/Integration/Foundation/Console/RouteCacheCommandTest.php +++ b/tests/Integration/Foundation/Console/RouteCacheCommandTest.php @@ -270,6 +270,28 @@ public function testRouteCacheRebuildsFromSourceWhenApplicationBootedWithExistin $this->assertSame('beta', $route->uri()); } + public function testRouteCacheForwardsExplicitEnvironmentToItsBuildProcess(): void + { + $this->defineTestbenchRoutes( + <<<'PHP' + Route::get('/' . app()->environment(), fn () => 'ok')->name('environment.route'); + PHP + ); + + $originalArguments = $_SERVER['argv']; + $_SERVER['argv'] = ['artisan', 'route:cache', '--env=doctor']; + + try { + $this->artisan('route:cache')->assertSuccessful(); + + require $this->app->getCachedRoutesPath(); + + $this->assertSame('doctor', $this->app->make('router')->getRoutes()->getByName('environment.route')?->uri()); + } finally { + $_SERVER['argv'] = $originalArguments; + } + } + public function testRouteCacheSubprocessUsesTheParentsResolvedCachePath(): void { $previousCachePath = $_SERVER['APP_ROUTES_CACHE'] ?? null; diff --git a/tests/Integration/Grpc/Fixtures/server.php b/tests/Integration/Grpc/Fixtures/server.php index c929e7f5b8..28df3830d2 100644 --- a/tests/Integration/Grpc/Fixtures/server.php +++ b/tests/Integration/Grpc/Fixtures/server.php @@ -22,8 +22,6 @@ Bootstrapper::bootstrap(); -putenv('APP_RUNNING_IN_CONSOLE=false'); -$_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; $port = (int) (env('GRPC_TEST_SERVER_PORT') ?: 19520); diff --git a/tests/Integration/Http/Middleware/PreventRequestForgeryServerRuntimeTest.php b/tests/Integration/Http/Middleware/PreventRequestForgeryServerRuntimeTest.php index 31407df0d8..b081ea5ca3 100644 --- a/tests/Integration/Http/Middleware/PreventRequestForgeryServerRuntimeTest.php +++ b/tests/Integration/Http/Middleware/PreventRequestForgeryServerRuntimeTest.php @@ -33,8 +33,6 @@ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - putenv('APP_RUNNING_IN_CONSOLE=false'); - $_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; $app->setRunningInConsole(false); @@ -42,10 +40,7 @@ protected function defineEnvironment(ApplicationContract $app): void protected function tearDown(): void { - PreventRequestForgery::flushState(); - - putenv('APP_RUNNING_IN_CONSOLE'); - unset($_ENV['APP_RUNNING_IN_CONSOLE'], $_SERVER['APP_RUNNING_IN_CONSOLE']); + unset($_SERVER['APP_RUNNING_IN_CONSOLE']); parent::tearDown(); } diff --git a/tests/Integration/HttpServer/Fixtures/server.php b/tests/Integration/HttpServer/Fixtures/server.php index 8c2e43c963..2d1b4504fd 100644 --- a/tests/Integration/HttpServer/Fixtures/server.php +++ b/tests/Integration/HttpServer/Fixtures/server.php @@ -37,8 +37,6 @@ Bootstrapper::bootstrap(); -putenv('APP_RUNNING_IN_CONSOLE=false'); -$_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; $app = TestbenchApplication::create(); diff --git a/tests/Integration/Reverb/Fixtures/server.php b/tests/Integration/Reverb/Fixtures/server.php index 97c5daa045..9edc7d4f52 100644 --- a/tests/Integration/Reverb/Fixtures/server.php +++ b/tests/Integration/Reverb/Fixtures/server.php @@ -50,8 +50,6 @@ // The app must not think it's running in console — ServerStartCommand // refuses to start and service providers gate console-only registrations. -putenv('APP_RUNNING_IN_CONSOLE=false'); -$_ENV['APP_RUNNING_IN_CONSOLE'] = 'false'; $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; // Read port, scaling flag, and worker count from environment. diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php index 7e8d3d9c5b..e81144a5c4 100644 --- a/tests/Queue/FailoverQueueTest.php +++ b/tests/Queue/FailoverQueueTest.php @@ -86,23 +86,26 @@ public static function forwardedQueueOperations(): array ]; } - public function testPushFailsOverOnException(): void + #[DataProvider('failedConnectionNames')] + public function testPushFailsOverOnException(UnitEnum|string|null $connection, ?string $expectedName): void { $queue = m::mock(QueueManager::class); $events = m::mock(DispatcherContract::class); $failover = new FailoverQueue($queue, $events, [ - 'redis', + $connection, 'sync', ]); $redis = m::mock(RedisQueue::class); - $queue->expects('connection')->with('redis')->andReturn($redis); + $queue->expects('connection')->with($connection)->andReturn($redis); $sync = m::mock(SyncQueue::class); $queue->expects('connection')->with('sync')->andReturn($sync); $events->expects('hasListeners')->with(QueueFailedOver::class)->andReturnTrue(); - $events->expects('dispatch'); + $events->expects('dispatch')->with(m::on( + fn (QueueFailedOver $event): bool => $event->connectionName === $expectedName, + )); $redis->expects('push')->andReturnUsing( fn (): never => throw new Exception('error') @@ -113,6 +116,39 @@ public function testPushFailsOverOnException(): void $failover->push('some-job'); } + /** + * Provide supported failover connection names. + */ + public static function failedConnectionNames(): array + { + return [ + 'named connection' => ['redis', 'redis'], + 'enum connection' => [IntegerQueueName::Zero, '0'], + 'default connection' => [null, null], + ]; + } + + public function testReplacementQueueReportsItsOwnFirstFailure(): void + { + $manager = m::mock(QueueManager::class); + $events = m::mock(DispatcherContract::class); + $primary = m::mock(Queue::class); + $secondary = m::mock(Queue::class); + $primary->allows('push')->andThrow(new RuntimeException('Primary unavailable.')); + $secondary->allows('push')->andReturn('job-id'); + $manager->allows('connection')->with('primary')->andReturn($primary); + $manager->allows('connection')->with('secondary')->andReturn($secondary); + $events->allows('hasListeners')->with(QueueFailedOver::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->twice()->with(m::type(QueueFailedOver::class)); + + $queue = new FailoverQueue($manager, $events, ['primary', 'secondary']); + $this->assertSame('job-id', $queue->push('job')); + unset($queue); + + $replacement = new FailoverQueue($manager, $events, ['primary', 'secondary']); + $this->assertSame('job-id', $replacement->push('job')); + } + public function testCancellationDoesNotFailOverOrEmitAnEvent(): void { $manager = m::mock(QueueManager::class); diff --git a/tests/Queue/ListFailedCommandTest.php b/tests/Queue/ListFailedCommandTest.php index dc2d2bfb84..d35d08b04c 100644 --- a/tests/Queue/ListFailedCommandTest.php +++ b/tests/Queue/ListFailedCommandTest.php @@ -9,8 +9,10 @@ use Hypervel\Queue\Failed\FailedJobProviderInterface; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; class ListFailedCommandTest extends TestCase { @@ -22,13 +24,15 @@ public function testItDisplaysEmptyFailedJobsAsJson(): void $this->assertJsonStringEqualsJsonString('[]', $output); } - public function testItDisplaysFailedJobsAsJson(): void + #[DataProvider('outputDecoration')] + public function testItDisplaysFailedJobsAsJson(bool $decorated): void { + $queue = 'Queue\name \literal'; $output = $this->runCommandWithFailedJobs([ (object) [ 'id' => 'failed-job-id', 'connection' => 'redis', - 'queue' => 'default', + 'queue' => $queue, 'payload' => json_encode([ 'job' => 'Hypervel\Queue\CallQueuedHandler@call', 'data' => [ @@ -38,27 +42,35 @@ public function testItDisplaysFailedJobsAsJson(): void 'exception' => 'Exception stack trace', 'failed_at' => '2026-05-18 12:00:00', ], - ], ['--json' => true]); + ], ['--json' => true], $decorated); $this->assertJson($output); $this->assertJsonStringEqualsJsonString(json_encode([ [ 'id' => 'failed-job-id', 'connection' => 'redis', - 'queue' => 'default', + 'queue' => $queue, 'class' => 'Hypervel\Tests\Queue\ExampleJob', 'failed_at' => '2026-05-18 12:00:00', ], ], JSON_THROW_ON_ERROR), $output); } + /** + * Provide terminal and redirected output modes. + */ + public static function outputDecoration(): array + { + return [[false], [true]]; + } + /** * Run the failed-job command with the given records. * * @param array $failedJobs * @param array $arguments */ - protected function runCommandWithFailedJobs(array $failedJobs, array $arguments = []): string + protected function runCommandWithFailedJobs(array $failedJobs, array $arguments = [], bool $decorated = false): string { $container = new Application; $failer = m::mock(FailedJobProviderInterface::class); @@ -69,7 +81,7 @@ protected function runCommandWithFailedJobs(array $failedJobs, array $arguments $command = new ListFailedCommand; $command->setHypervel($container); - $output = new BufferedOutput; + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, $decorated); $command->run(new ArrayInput($arguments), $output); diff --git a/tests/Queue/MonitorCommandTest.php b/tests/Queue/MonitorCommandTest.php index 2adfe04645..0b62992fc1 100644 --- a/tests/Queue/MonitorCommandTest.php +++ b/tests/Queue/MonitorCommandTest.php @@ -13,19 +13,23 @@ use Hypervel\Queue\Events\QueueBusy; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; class MonitorCommandTest extends TestCase { - public function testItDisplaysQueueMetricsAsJsonAndDispatchesBusyEvent(): void + #[DataProvider('outputDecoration')] + public function testItDisplaysQueueMetricsAsJsonAndDispatchesBusyEvent(bool $decorated): void { + $name = 'Queue\name \literal'; $queue = m::mock(Queue::class); - $queue->shouldReceive('size')->with('default')->andReturn(1001); - $queue->shouldReceive('pendingSize')->with('default')->andReturn(0); - $queue->shouldReceive('delayedSize')->with('default')->andReturn(2); - $queue->shouldReceive('reservedSize')->with('default')->andReturn(3); - $queue->shouldReceive('creationTimeOfOldestPendingJob')->with('default')->andReturn(null); + $queue->shouldReceive('size')->with($name)->andReturn(1001); + $queue->shouldReceive('pendingSize')->with($name)->andReturn(0); + $queue->shouldReceive('delayedSize')->with($name)->andReturn(2); + $queue->shouldReceive('reservedSize')->with($name)->andReturn(3); + $queue->shouldReceive('creationTimeOfOldestPendingJob')->with($name)->andReturn(null); $manager = m::mock(Factory::class); $manager->shouldReceive('connection')->with('redis')->andReturn($queue); @@ -36,18 +40,33 @@ public function testItDisplaysQueueMetricsAsJsonAndDispatchesBusyEvent(): void ->once() ->with(m::on(fn (mixed $event): bool => $event instanceof QueueBusy && $event->connectionName === 'redis' - && $event->queue === 'default' + && $event->queue === $name && $event->size === 1001)); $output = $this->runCommand( new MonitorCommand($manager, $events, new Repository(['queue' => ['default' => 'sync']])), - ['queues' => 'redis:default', '--json' => true], + ['queues' => 'redis:' . $name, '--json' => true], + $decorated, ); - $this->assertJsonStringEqualsJsonString( - '[{"connection":"redis","queue":"default","size":1001,"pending":0,"delayed":2,"reserved":3,"oldest_pending":null,"status":"ALERT"}]', - $output, - ); + $this->assertSame([[ + 'connection' => 'redis', + 'queue' => $name, + 'size' => 1001, + 'pending' => 0, + 'delayed' => 2, + 'reserved' => 3, + 'oldest_pending' => null, + 'status' => 'ALERT', + ]], json_decode($output, true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * Provide terminal and redirected output modes. + */ + public static function outputDecoration(): array + { + return [[false], [true]]; } public function testItDisplaysDetailedQueueMetricsForTheDefaultConnection(): void @@ -84,11 +103,11 @@ public function testItDisplaysDetailedQueueMetricsForTheDefaultConnection(): voi * * @param array $arguments */ - private function runCommand(MonitorCommand $command, array $arguments): string + private function runCommand(MonitorCommand $command, array $arguments, bool $decorated = false): string { $command->setHypervel(new Application); - $output = new BufferedOutput; + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, $decorated); $command->run(new ArrayInput($arguments), $output); return $output->fetch(); diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 703f3d9274..4fce4c86bd 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -21,12 +21,15 @@ use Pheanstalk\Contract\PheanstalkManagerInterface; use Pheanstalk\Contract\PheanstalkPublisherInterface; use Pheanstalk\Contract\PheanstalkSubscriberInterface; +use Pheanstalk\Exception\TubeNotFoundException; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Job; use Pheanstalk\Values\ServerStats; use Pheanstalk\Values\TubeList; use Pheanstalk\Values\TubeName; use Pheanstalk\Values\TubeStats; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use Symfony\Component\Uid\Uuid; class QueueBeanstalkdQueueTest extends TestCase @@ -74,6 +77,35 @@ public function testSizeIncludesPendingDelayedAndReservedJobsWithOneStatsRequest $this->assertSame(12, $this->queue->size('stack')); } + #[DataProvider('tubeCountMethods')] + public function testAnAbsentTubeHasNoJobs(string $method): void + { + $this->setQueue('default', 60); + $this->queue->getPheanstalk()->shouldReceive('statsTube') + ->once()->andThrow(new TubeNotFoundException(new TubeName('unused'))); + + $this->assertSame(0, $this->queue->{$method}('unused')); + } + + /** + * Provide queue size methods backed by tube statistics. + */ + public static function tubeCountMethods(): array + { + return [['size'], ['pendingSize'], ['delayedSize'], ['reservedSize']]; + } + + public function testTubeStatisticsDoNotHideConnectionFailures(): void + { + $this->setQueue('default', 60); + $failure = new RuntimeException('Beanstalkd connection failed.'); + $this->queue->getPheanstalk()->shouldReceive('statsTube')->once()->andThrow($failure); + + $this->expectExceptionObject($failure); + + $this->queue->size(); + } + public function testTotalSizesUseOneServerStatsRequestPerCount(): void { $this->setQueue('default', 60); diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index 13ef340b9f..6c681e9e9f 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -11,16 +11,21 @@ use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\ClearableQueue; use Hypervel\Contracts\Queue\Queue; +use Hypervel\Events\Dispatcher; use Hypervel\ObjectPool\PoolManager; use Hypervel\Queue\ClearableQueuePoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; +use Hypervel\Queue\Connectors\FailoverConnector; +use Hypervel\Queue\Connectors\NullConnector; use Hypervel\Queue\Jobs\Job; use Hypervel\Queue\NullQueue; use Hypervel\Queue\QueueManager; use Hypervel\Queue\QueuePoolProxy; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class QueueManagerTest extends TestCase { @@ -174,6 +179,83 @@ public function testSetDefaultDriverAcceptsBackedEnum(): void $this->assertSame('sync', $container->make('config')->get('queue.default')); } + #[DataProvider('circularConnections')] + public function testRejectsCircularFailoverDefinitions(array $connections, string $cycle): void + { + $manager = $this->getFailoverManager($connections); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('Circular queue connection definition detected: ' . $cycle . '.'); + + $manager->connection('main'); + } + + /** + * Provide circular failover configurations. + */ + public static function circularConnections(): array + { + return [ + 'self' => [[ + 'main' => ['driver' => 'failover', 'connections' => ['main']], + ], 'main -> main'], + 'indirect' => [[ + 'main' => ['driver' => 'failover', 'connections' => ['first']], + 'first' => ['driver' => 'failover', 'connections' => ['second']], + 'second' => ['driver' => 'failover', 'connections' => ['first']], + ], 'first -> second -> first'], + ]; + } + + public function testNestedFailoverConnectionsCanShareAChild(): void + { + $manager = $this->getFailoverManager([ + 'main' => ['driver' => 'failover', 'connections' => ['first', 'second']], + 'first' => ['driver' => 'failover', 'connections' => ['leaf']], + 'second' => ['driver' => 'failover', 'connections' => ['leaf']], + 'leaf' => ['driver' => 'null'], + ]); + + $this->assertSame(0, $manager->connection('main')->size()); + $this->assertSame(0, $manager->connection('second')->size()); + } + + public function testCustomConnectorCanReplaceFailover(): void + { + $manager = $this->getFailoverManager([ + 'main' => ['driver' => 'failover', 'connections' => ['main']], + ]); + $manager->extend('failover', fn (): NullConnector => new NullConnector); + + $this->assertSame(0, $manager->connection('main')->size()); + } + + public function testMissingBackupConnectionRemainsDeferred(): void + { + $manager = $this->getFailoverManager([ + 'main' => ['driver' => 'failover', 'connections' => ['healthy', 'missing']], + 'healthy' => ['driver' => 'null'], + ]); + + $this->assertSame(0, $manager->connection('main')->size()); + } + + public function testBackupConnectorFactoryFailureRemainsDeferred(): void + { + $manager = $this->getFailoverManager([ + 'main' => ['driver' => 'failover', 'connections' => ['healthy', 'backup']], + 'healthy' => ['driver' => 'null'], + 'backup' => ['driver' => 'custom', 'connections' => ['remote']], + ]); + $failure = new RuntimeException('Backup dependency unavailable'); + $manager->extend('custom', fn () => throw $failure); + + $this->assertSame(0, $manager->connection('main')->size()); + + $this->expectExceptionObject($failure); + $manager->connection('backup'); + } + public function testAddPoolableConnector(): void { $container = $this->getContainer(); @@ -441,6 +523,20 @@ public function testPayloadCallbacksCanBeRegisteredWithoutResolvingTheDefaultCon $this->assertFalse($manager->connected('missing')); } + /** + * Create a manager with real failover and null connectors. + */ + protected function getFailoverManager(array $connections): QueueManager + { + $container = $this->getContainer(); + $container->make('config')->set('queue.connections', $connections); + $manager = new QueueManager($container); + $manager->addConnector('failover', fn (): FailoverConnector => new FailoverConnector($manager, new Dispatcher($container))); + $manager->addConnector('null', fn (): NullConnector => new NullConnector); + + return $manager; + } + protected function getContainer(): Container { $container = new Container; diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php index ae3cbc2f17..1825605406 100644 --- a/tests/Queue/WorkCommandTest.php +++ b/tests/Queue/WorkCommandTest.php @@ -102,28 +102,28 @@ public function testFractionalSleepAndRestSecondsAreKept(): void } #[DataProvider('queueStatusOutputProvider')] - public function testQueueStatusOutputUsesTheCurrentCommand(bool $json): void + public function testQueueStatusOutputUsesTheCurrentCommand(bool $json, bool $decorated, string $queue): void { $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); - $arguments = ['--once' => true, '--sleep' => 0, '--json' => $json]; + $arguments = ['--once' => true, '--sleep' => 0, '--json' => $json, '--queue' => $queue]; - Queue::pause('default', 'sync'); - $firstOutput = new BufferedOutput; + Queue::pause($queue, 'sync'); + $firstOutput = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, $decorated); $this->assertSame(0, Artisan::call('queue:work', $arguments, $firstOutput)); if ($json) { $this->assertSame([ 'level' => 'warning', - 'queue' => 'default', + 'queue' => $queue, 'status' => 'paused', 'timestamp' => '2023-01-18T10:10:11.000000+00:00', ], json_decode($firstOutput->fetch(), true, 512, JSON_THROW_ON_ERROR)); } else { - $this->assertSame(" 2023-01-18 10:10:11 Queue default PAUSED\n", $firstOutput->fetch()); + $this->assertSame(" 2023-01-18 10:10:11 Queue {$queue} PAUSED\n", $firstOutput->fetch()); } - Queue::resume('default', 'sync'); - $secondOutput = new BufferedOutput; + Queue::resume($queue, 'sync'); + $secondOutput = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, $decorated); $this->assertSame(0, Artisan::call('queue:work', $arguments, $secondOutput)); $this->assertSame('', $firstOutput->fetch()); @@ -131,12 +131,12 @@ public function testQueueStatusOutputUsesTheCurrentCommand(bool $json): void if ($json) { $this->assertSame([ 'level' => 'warning', - 'queue' => 'default', + 'queue' => $queue, 'status' => 'resumed', 'timestamp' => '2023-01-18T10:10:11.000000+00:00', ], json_decode($secondOutput->fetch(), true, 512, JSON_THROW_ON_ERROR)); } else { - $this->assertSame(" 2023-01-18 10:10:11 Queue default RESUMED\n", $secondOutput->fetch()); + $this->assertSame(" 2023-01-18 10:10:11 Queue {$queue} RESUMED\n", $secondOutput->fetch()); } } @@ -146,8 +146,10 @@ public function testQueueStatusOutputUsesTheCurrentCommand(bool $json): void public static function queueStatusOutputProvider(): array { return [ - 'CLI' => [false], - 'JSON' => [true], + 'CLI' => [false, false, 'default'], + 'JSON' => [true, false, 'default'], + 'JSON literal text' => [true, false, 'Queue\name \literal'], + 'JSON decorated literal text' => [true, true, 'Queue\name \literal'], ]; } diff --git a/tests/Redis/RedisRequirementsTest.php b/tests/Redis/RedisRequirementsTest.php new file mode 100644 index 0000000000..6e1797da39 --- /dev/null +++ b/tests/Redis/RedisRequirementsTest.php @@ -0,0 +1,67 @@ +assertNull($error); + } else { + $this->assertStringContainsString($version, $error); + $this->assertStringContainsString(RedisRequirements::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION, $error); + } + } + + /** + * Provide client versions around the feature requirement. + */ + public static function clientVersions(): array + { + return [ + 'missing' => ['0', false], + 'older' => ['6.2.0', false], + 'prerelease' => ['6.3.0RC1', false], + 'minimum' => ['6.3.0', true], + 'newer' => ['7.0.0', true], + ]; + } + + #[DataProvider('serverVersions')] + public function testServerRequirement(array $info, ?string $errorContains): void + { + $error = RedisRequirements::hashFieldExpirationServerError($info); + + if ($errorContains === null) { + $this->assertNull($error); + } else { + $this->assertStringContainsString($errorContains, $error); + } + } + + /** + * Provide server versions, including Valkey's Redis compatibility field. + */ + public static function serverVersions(): array + { + return [ + 'unknown' => [[], 'Could not determine'], + 'old Redis' => [['redis_version' => '7.4.0'], 'Redis >= 8.0.0'], + 'minimum Redis' => [['redis_version' => '8.0.0'], null], + 'new Redis' => [['redis_version' => '9.0.0'], null], + 'old Valkey' => [['valkey_version' => '8.1.0', 'redis_version' => '8.0.0'], 'Valkey >= 9.0.0'], + 'minimum Valkey' => [['valkey_version' => '9.0.0', 'redis_version' => '7.2.0'], null], + 'new Valkey' => [['valkey_version' => '10.0.0'], null], + ]; + } +} diff --git a/tests/Session/SessionConfigTest.php b/tests/Session/SessionConfigTest.php index 4bbfb3b65d..bfbe68791b 100644 --- a/tests/Session/SessionConfigTest.php +++ b/tests/Session/SessionConfigTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Session; +use Hypervel\Session\SessionManager; use Hypervel\Support\Env; use Hypervel\Testbench\TestCase; @@ -71,6 +72,9 @@ public function testSessionConfigurationDeclaresCanonicalDefaults(): void $this->assertSame(10, $config['block_wait_seconds']); $this->assertNull($config['secure']); $this->assertSame('json', $config['serialization']); + + $database = require dirname(__DIR__, 2) . '/src/foundation/config/database.php'; + $this->assertArrayHasKey(SessionManager::DEFAULT_REDIS_CONNECTION, $database['redis']); } finally { $this->restoreEnvironmentVariables($originalValues); Env::flushRepository(); diff --git a/tests/Support/DotenvManagerTest.php b/tests/Support/DotenvManagerTest.php index a5ab806f45..90899fd7f7 100644 --- a/tests/Support/DotenvManagerTest.php +++ b/tests/Support/DotenvManagerTest.php @@ -43,6 +43,32 @@ public function testLoad(): void $this->assertTrue(env('OLD_FLAG')); } + public function testLoadedVariableNamesExcludeExternalValuesAndFollowReloads(): void + { + $originalVersion = $_ENV['TEST_VERSION'] ?? null; + $_ENV['TEST_VERSION'] = 'external'; + + try { + $this->assertSame([], DotenvManager::getLoadedVariableNames()); + + DotenvManager::load([__DIR__ . '/Fixtures/envs/oldEnv']); + + $this->assertSame(['OLD_FLAG', 'SW_VERSION'], DotenvManager::getLoadedVariableNames()); + $this->assertSame('external', Env::get('TEST_VERSION')); + + DotenvManager::reload([__DIR__ . '/Fixtures/envs/newEnv']); + + $this->assertSame(['NEW_FLAG', 'SW_VERSION'], DotenvManager::getLoadedVariableNames()); + $this->assertSame('external', Env::get('TEST_VERSION')); + } finally { + if ($originalVersion === null) { + unset($_ENV['TEST_VERSION']); + } else { + $_ENV['TEST_VERSION'] = $originalVersion; + } + } + } + public function testLoadIsIdempotent(): void { DotenvManager::load([__DIR__ . '/Fixtures/envs/oldEnv']); diff --git a/tests/Testbench/CommanderEnvironmentTest.php b/tests/Testbench/CommanderEnvironmentTest.php index 9f8f9f7e26..a5ac52484b 100644 --- a/tests/Testbench/CommanderEnvironmentTest.php +++ b/tests/Testbench/CommanderEnvironmentTest.php @@ -30,8 +30,8 @@ public function itMarksServeCommandsAsNonConsoleBeforeBootstrappingTheApplicatio $commander->prepareCommandEnvironmentPublic(new ArgvInput(['testbench', 'serve'])); - $this->assertSame('false', getenv('APP_RUNNING_IN_CONSOLE')); - $this->assertSame('false', $_ENV['APP_RUNNING_IN_CONSOLE']); + $this->assertFalse(getenv('APP_RUNNING_IN_CONSOLE')); + $this->assertArrayNotHasKey('APP_RUNNING_IN_CONSOLE', $_ENV); $this->assertSame('false', $_SERVER['APP_RUNNING_IN_CONSOLE']); } @@ -47,8 +47,8 @@ public function itMarksServeCommandsAfterSeparatedEnvironmentValuesAsNonConsole( 'serve', ])); - $this->assertSame('false', getenv('APP_RUNNING_IN_CONSOLE')); - $this->assertSame('false', $_ENV['APP_RUNNING_IN_CONSOLE']); + $this->assertFalse(getenv('APP_RUNNING_IN_CONSOLE')); + $this->assertArrayNotHasKey('APP_RUNNING_IN_CONSOLE', $_ENV); $this->assertSame('false', $_SERVER['APP_RUNNING_IN_CONSOLE']); } diff --git a/tests/Testbench/PackageMetadataTest.php b/tests/Testbench/PackageMetadataTest.php index bbb2f3e827..5186920ac6 100644 --- a/tests/Testbench/PackageMetadataTest.php +++ b/tests/Testbench/PackageMetadataTest.php @@ -33,9 +33,11 @@ public function testDirectRuntimeDependenciesAreDeclared(): void JSON_THROW_ON_ERROR, ); - $this->assertArrayHasKey('composer/semver', $rootComposer['require-dev']); + $rootRequirements = $rootComposer['require'] + $rootComposer['require-dev']; + + $this->assertArrayHasKey('composer/semver', $rootRequirements); $this->assertArrayHasKey('composer/semver', $composer['require']); - $this->assertSame($rootComposer['require-dev']['composer/semver'], $composer['require']['composer/semver']); + $this->assertSame($rootRequirements['composer/semver'], $composer['require']['composer/semver']); $this->assertArrayHasKey('hypervel/di', $composer['require']); $this->assertSame('^0.4', $composer['require']['hypervel/di']); $this->assertArrayHasKey('hypervel/di', $rootComposer['replace']);