From f4748dccc3340b8dd2c4e0e06cfb957c73189113 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:23:51 +0000 Subject: [PATCH 01/27] Raise the Guzzle 7 floor to 7.15.2 inertiajs/inertia-laravel#891 adds Guzzle 8 support and requires at least Guzzle 7.15.2 on the 7.x line. Hypervel already allows Guzzle 8 framework-wide; this raises the 7.x floor to ^7.15.2 in the root manifest and every split package that requires Guzzle, so installs cannot resolve a release affected by GHSA-v5mv-p594-2x33 or GHSA-f7vp-7xgx-4w4r. The api-client and inertia manifests also declare hypervel/collections, which both packages use directly (Arr, Collection and collect()) but received only transitively. The inertia manifest also declares hypervel/filesystem for the DevTools entry repository. Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da. Validation: composer validate for each split manifest, PackageMetadataTest and ComposerFileTest. --- composer.json | 2 +- src/api-client/composer.json | 3 ++- src/broadcasting/composer.json | 2 +- src/console/composer.json | 2 +- src/foundation/composer.json | 2 +- src/http/composer.json | 2 +- src/inertia/composer.json | 4 +++- src/notifications/composer.json | 2 +- src/opentelemetry/composer.json | 2 +- src/saloon/composer.json | 2 +- src/scout/composer.json | 2 +- src/sentry/composer.json | 2 +- src/socialite/composer.json | 2 +- src/telescope/composer.json | 2 +- 14 files changed, 17 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 7a9905feb..2fdc01eb7 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "fruitcake/php-cors": "^1.3", "google/common-protos": "^4.14", "google/protobuf": "^5.35", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "guzzlehttp/uri-template": "^1.0 || ^2.0", diff --git a/src/api-client/composer.json b/src/api-client/composer.json index 527df7632..4534e06cc 100644 --- a/src/api-client/composer.json +++ b/src/api-client/composer.json @@ -25,9 +25,10 @@ ], "require": { "php": "^8.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "psr/http-message": "^2.0", + "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index a7bd3af30..db6b02dba 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -25,7 +25,7 @@ }, "require": { "php": "^8.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", "hypervel/connection-pool": "^0.4", diff --git a/src/console/composer.json b/src/console/composer.json index 4df72643c..07f4cdc96 100644 --- a/src/console/composer.json +++ b/src/console/composer.json @@ -31,7 +31,7 @@ "ext-posix": "*", "ext-swoole": "^6.2.2", "dragonmantank/cron-expression": "^3.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/bus": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/foundation/composer.json b/src/foundation/composer.json index c2629c199..512db498a 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -28,7 +28,7 @@ "ext-filter": "*", "ext-posix": "*", "brick/math": "^1.0", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "laravel/serializable-closure": "^2.0.11", "league/flysystem": "^3.25.1", "league/uri": "^7.5.1", diff --git a/src/http/composer.json b/src/http/composer.json index 7483ba70f..55eec8131 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -27,7 +27,7 @@ "php": "^8.4", "ext-filter": "*", "fruitcake/php-cors": "^1.3", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "guzzlehttp/uri-template": "^1.0 || ^2.0", diff --git a/src/inertia/composer.json b/src/inertia/composer.json index f7d6add29..ff144e9d2 100644 --- a/src/inertia/composer.json +++ b/src/inertia/composer.json @@ -33,15 +33,17 @@ }, "require": { "php": "^8.4", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "symfony/console": "^8.1.2", "symfony/http-foundation": "^8.1", "symfony/process": "^8.1", + "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", diff --git a/src/notifications/composer.json b/src/notifications/composer.json index 1172fd2fc..e29f208d5 100644 --- a/src/notifications/composer.json +++ b/src/notifications/composer.json @@ -26,7 +26,7 @@ "require": { "php": "^8.4", "ext-mbstring": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/broadcasting": "^0.4", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/opentelemetry/composer.json b/src/opentelemetry/composer.json index ff22180f5..2e60951a6 100644 --- a/src/opentelemetry/composer.json +++ b/src/opentelemetry/composer.json @@ -35,7 +35,7 @@ "ext-filter": "*", "ext-mbstring": "*", "ext-swoole": "^6.2.2", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "hypervel/cache": "^0.4", "hypervel/config": "^0.4", diff --git a/src/saloon/composer.json b/src/saloon/composer.json index 4d512bda5..5445024de 100644 --- a/src/saloon/composer.json +++ b/src/saloon/composer.json @@ -36,7 +36,7 @@ "ext-filter": "*", "ext-mbstring": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/psr7": "^2.13 || ^3.1", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/scout/composer.json b/src/scout/composer.json index 949e5e796..4ce5c2de5 100644 --- a/src/scout/composer.json +++ b/src/scout/composer.json @@ -37,7 +37,7 @@ "require": { "php": "^8.4", "ext-filter": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/config": "^0.4", diff --git a/src/sentry/composer.json b/src/sentry/composer.json index fe42294c4..c2747b62e 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -31,7 +31,7 @@ "require": { "php": "^8.4", "ext-filter": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/socialite/composer.json b/src/socialite/composer.json index 2686e02b1..1ed7762b8 100644 --- a/src/socialite/composer.json +++ b/src/socialite/composer.json @@ -32,7 +32,7 @@ "php": "^8.4", "ext-filter": "*", "firebase/php-jwt": "^7.0", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "psr/http-message": "^2.0", "hypervel/collections": "^0.4", "hypervel/context": "^0.4", diff --git a/src/telescope/composer.json b/src/telescope/composer.json index f16f48d16..fa2b70e93 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -26,7 +26,7 @@ "php": "^8.4", "ext-mbstring": "*", "ext-pdo": "*", - "guzzlehttp/guzzle": "^7.15.1 || ^8.2", + "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "hypervel/auth": "^0.4", "hypervel/broadcasting": "^0.4", From ecc4b9803d2ce23666be5df0d375b3b0cc6e73f3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:23:58 +0000 Subject: [PATCH 02/27] Declare direct collections dependencies The concurrency, grpc and object-pool packages import Hypervel\Support\Arr or Hypervel\Support\Collection, which hypervel/collections provides, but their split manifests did not require it. They received the package only transitively. Each manifest now declares hypervel/collections directly. A scan of every split package found no other undeclared filesystem or collections imports; api-client and inertia gained the same requirement alongside their Guzzle floor change. Validation: composer validate for each manifest, PackageMetadataTest and ComposerFileTest. --- src/concurrency/composer.json | 1 + src/grpc/composer.json | 1 + src/object-pool/composer.json | 1 + 3 files changed, 3 insertions(+) diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json index f819964e2..0d0e6cad6 100644 --- a/src/concurrency/composer.json +++ b/src/concurrency/composer.json @@ -26,6 +26,7 @@ }, "require": { "php": "^8.4", + "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", diff --git a/src/grpc/composer.json b/src/grpc/composer.json index 5838abacb..bd3bb00d4 100644 --- a/src/grpc/composer.json +++ b/src/grpc/composer.json @@ -36,6 +36,7 @@ "ext-zlib": "*", "google/common-protos": "^4.14", "google/protobuf": "^5.35", + "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", diff --git a/src/object-pool/composer.json b/src/object-pool/composer.json index 9ec0ac361..de1501b43 100644 --- a/src/object-pool/composer.json +++ b/src/object-pool/composer.json @@ -25,6 +25,7 @@ ], "require": { "php": "^8.4", + "hypervel/collections": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", From e988e8b9297f154bee5bbc9728be9672f21ff40e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:24:11 +0000 Subject: [PATCH 03/27] Port the remaining Inertia SSR gateway tests Two upstream HttpGateway tests were missing or differed from the port: - inertiajs/inertia-laravel#817 added test_it_does_not_throw_exception_when_throw_on_error_is_disabled, which checks that a failed render returns null when throw_on_error is false. - inertiajs/inertia-laravel#885 asserts the head and body returned through a configured hot URL. Hypervel's equivalent now uses the upstream name, testItUsesConfiguredHotUrlWhenRunningHot, and the same response assertions alongside its URI check. The gateway source already matched upstream. Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da. Validation: HttpGatewayTest and the Inertia suite. --- tests/Inertia/HttpGatewayTest.php | 32 +++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/Inertia/HttpGatewayTest.php b/tests/Inertia/HttpGatewayTest.php index 0528eb72b..b82b862d4 100644 --- a/tests/Inertia/HttpGatewayTest.php +++ b/tests/Inertia/HttpGatewayTest.php @@ -270,7 +270,7 @@ public function testItUsesViteHotUrlWhenRunningHot(): void $this->assertSame('http://localhost:5173/__inertia_ssr', (string) $lastRequest->getUri()); } - public function testItPrefersTheConfiguredHotUrl(): void + public function testItUsesConfiguredHotUrlWhenRunningHot(): void { config([ 'inertia.ssr.enabled' => true, @@ -281,12 +281,16 @@ public function testItPrefersTheConfiguredHotUrl(): void $mock = $this->mockSsrClient([ new GuzzleResponse(200, [], json_encode([ - 'head' => [], - 'body' => '
Hot Response
', + 'head' => ['Custom Hot SSR'], + 'body' => '
Custom Hot Response
', ])), ]); - $this->assertNotNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertNotNull($response); + $this->assertEquals('Custom Hot SSR', $response->head); + $this->assertEquals('
Custom Hot Response
', $response->body); $this->assertSame( 'http://localhost:4173/base/__inertia_ssr', (string) $mock->getLastRequest()->getUri(), @@ -711,6 +715,26 @@ public function testDisableWhenTakesPrecedenceOverConfig(): void $this->assertNotNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); } + public function testItDoesNotThrowExceptionWhenThrowOnErrorIsDisabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => false, + ]); + + $this->mockSsrClient([ + new GuzzleResponse(500, [], json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + ])), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + } + public function testItDoesNotThrowExceptionWhenThrowOnErrorIsOmitted(): void { Event::fake([SsrRenderFailed::class]); From 8c63ef693f5b1485f8e2c04d697422d6b119ca83 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:24:18 +0000 Subject: [PATCH 04/27] Align the Inertia SSR state isolation test with upstream inertiajs/inertia-laravel#848 added test_ssr_state_is_scoped_and_does_not_leak_between_requests for the request-scoped SsrState. Hypervel keeps that state in the coroutine-scoped InertiaState, and its equivalent test now uses the upstream name and dispatches through InertiaState::dispatchSsr(), as upstream's test does through SsrState, instead of setting the dispatch fields by hand. Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da. Validation: ComponentTest and the Inertia suite. --- tests/Inertia/ComponentTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Inertia/ComponentTest.php b/tests/Inertia/ComponentTest.php index d01b7b41b..347707947 100644 --- a/tests/Inertia/ComponentTest.php +++ b/tests/Inertia/ComponentTest.php @@ -229,14 +229,13 @@ public function testAppComponentRendersCurrentPageNotPreviousRender(): void $this->assertStringNotContainsString('"component":"FirstPage"', $second); } - public function testInertiaStateDoesNotLeakBetweenRequests(): void + public function testSsrStateIsScopedAndDoesNotLeakBetweenRequests(): void { Config::set(['inertia.ssr.enabled' => true]); $state1 = InertiaState::current(); $state1->page = self::EXAMPLE_PAGE_OBJECT; - $state1->ssrDispatched = true; - $state1->ssrResponse = app(Gateway::class)->dispatch($state1->page); + $state1->dispatchSsr(); $this->assertNotNull($state1->ssrResponse); From 9a94266199726a8597b710eee09cd843ff06decd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:24:24 +0000 Subject: [PATCH 05/27] Match upstream member order in SsrException Upstream declares SsrException::$event after fromEvent(). The port declared it first. Moving it restores upstream order so future merges line up; behavior is unchanged. Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da. --- src/inertia/src/Ssr/SsrException.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/inertia/src/Ssr/SsrException.php b/src/inertia/src/Ssr/SsrException.php index 8c6bae31d..e89242d08 100644 --- a/src/inertia/src/Ssr/SsrException.php +++ b/src/inertia/src/Ssr/SsrException.php @@ -8,11 +8,6 @@ class SsrException extends Exception { - /** - * The SSR render failed event containing error details. - */ - public ?SsrRenderFailed $event = null; - /** * Create a new SSR exception from a render failure event. */ @@ -34,6 +29,11 @@ public static function fromEvent(SsrRenderFailed $event): self return $exception; } + /** + * The SSR render failed event containing error details. + */ + public ?SsrRenderFailed $event = null; + /** * Get the component that failed to render. */ From f25a6f23c747b47f76a829a143052e144483b39c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:24:41 +0000 Subject: [PATCH 06/27] Add Inertia DevTools support Ports the server side of Inertia DevTools from inertiajs/inertia-laravel #892 and its follow-ups #894, #895, #896 and #897. While enabled, the adapter records each request (props and their Inertia types, shared-prop and render sources, route, headers and bodies) to local JSON entries and serves them to the browser extension from /_inertia/devtools/entries. Recording is limited to the local environment unless INERTIA_DEVTOOLS_ENABLED says otherwise, and the endpoints outside local require the configured gate. Hypervel adaptations: - The RequestHandled flush listener is registered only when DevTools is enabled at boot, so production requests pay nothing for it. It flushes before the response is sent, so the extension can fetch the entry as soon as the headers arrive. - EntryStore, SourceLocator, IncomingEntryBuilder and RequestRecorder are scoped per coroutine; the builder holds the request's source locator. - Source capture also skips Hypervel's own framework files, so path repository and monorepo installs report the application call site. - Upstream's Octane sandbox test is replaced by a coroutine isolation test covering concurrent requests in one worker. - EntryStore::flushState() resets the circuit breaker between tests. Upstream defects fixed: - Pruning ran in the listener, so a storage failure while pruning became a 500. It now runs inside EntryStore::flush(), behind the same failure breaker as the save. - A missing, empty or corrupt index was treated as empty, so the next save dropped every earlier entry from it. The index is now reseeded from the entry files under its lock, and recovery no longer overwrites an entry saved after the index was read. - Nested props are recorded under their dotted path, which bypassed key-based redaction, so a value such as auth.token was stored unredacted. A value is now redacted when any segment of its path is a sensitive key. - A partial devtools config section fell back to empty exclusion and redaction lists. Omitted lists now use the shipped defaults, owned by DevTools::DEFAULT_*; an explicit empty list still turns them off. - A numeric prop key reached a string-typed source lookup and returned a 500 under strict types. The frontend documentation gains a DevTools section adapted from inertiajs/docs v3/advanced/devtools.mdx at c6a69bd613. Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da. Validation: every ported and added DevTools test file, the Inertia suite, PHPStan on the Inertia source and test subscriber, and php-cs-fixer. --- src/docs/frontend.md | 29 + src/inertia/config/inertia.php | 59 ++ src/inertia/src/DevTools/Collector.php | 321 ++++++++++ .../src/DevTools/Data/IncomingEntry.php | 96 +++ src/inertia/src/DevTools/Data/PropType.php | 15 + src/inertia/src/DevTools/Data/RequestType.php | 17 + src/inertia/src/DevTools/DevTools.php | 97 +++ src/inertia/src/DevTools/DevToolsHeader.php | 69 ++ .../src/DevTools/DevToolsServiceProvider.php | 82 +++ .../src/DevTools/EntriesRepository.php | 438 +++++++++++++ src/inertia/src/DevTools/EntryStore.php | 109 ++++ src/inertia/src/DevTools/Http/Authorize.php | 41 ++ .../src/DevTools/Http/EntriesController.php | 70 +++ .../src/DevTools/Http/PreserveFlashData.php | 31 + .../Http/PreventPreviousUrlTracking.php | 27 + .../src/DevTools/IncomingEntryBuilder.php | 587 ++++++++++++++++++ src/inertia/src/DevTools/PropClassifier.php | 133 ++++ .../src/DevTools/RedactsSensitiveData.php | 253 ++++++++ src/inertia/src/DevTools/RequestAttribute.php | 25 + src/inertia/src/DevTools/RequestRecorder.php | 361 +++++++++++ src/inertia/src/DevTools/SourceLocator.php | 277 +++++++++ src/inertia/src/InertiaServiceProvider.php | 16 +- src/inertia/src/Middleware.php | 15 +- src/inertia/src/PropsResolver.php | 14 + src/inertia/src/Response.php | 3 + src/inertia/src/ResponseFactory.php | 13 +- src/inertia/src/Support/Header.php | 5 + .../src/PHPUnit/AfterEachTestSubscriber.php | 1 + tests/Inertia/DevTools/AuthorizeGateTest.php | 110 ++++ .../DevTools/AuthorizeMiddlewareTest.php | 63 ++ tests/Inertia/DevTools/AuthorizeTest.php | 94 +++ .../DevTools/CollectorIntegrationTest.php | 500 +++++++++++++++ .../DevTools/CoroutineIsolationTest.php | 93 +++ tests/Inertia/DevTools/DevToolsTest.php | 82 +++ .../DevTools/EntriesRepositoryTest.php | 319 ++++++++++ tests/Inertia/DevTools/EntryStoreTest.php | 173 ++++++ tests/Inertia/DevTools/FlashDataTest.php | 91 +++ tests/Inertia/DevTools/HttpEndpointsTest.php | 149 +++++ .../IncomingEntryBuilderMatrixTest.php | 375 +++++++++++ .../DevTools/IncomingEntryBuilderTest.php | 76 +++ .../DevTools/InteractsWithDevToolsStorage.php | 66 ++ .../MiddlewareDevToolsDisabledTest.php | 74 +++ .../DevTools/MiddlewareDevToolsTest.php | 564 +++++++++++++++++ tests/Inertia/DevTools/PropClassifierTest.php | 298 +++++++++ .../DevTools/RecorderResilienceTest.php | 81 +++ .../DevTools/RedactsSensitiveDataTest.php | 222 +++++++ .../Fixtures/DevToolsRootViewMiddleware.php | 16 + tests/Inertia/Fixtures/devtools-app.blade.php | 5 + tests/Inertia/PropsResolverTest.php | 69 ++ 49 files changed, 6720 insertions(+), 4 deletions(-) create mode 100644 src/inertia/src/DevTools/Collector.php create mode 100644 src/inertia/src/DevTools/Data/IncomingEntry.php create mode 100644 src/inertia/src/DevTools/Data/PropType.php create mode 100644 src/inertia/src/DevTools/Data/RequestType.php create mode 100644 src/inertia/src/DevTools/DevTools.php create mode 100644 src/inertia/src/DevTools/DevToolsHeader.php create mode 100644 src/inertia/src/DevTools/DevToolsServiceProvider.php create mode 100644 src/inertia/src/DevTools/EntriesRepository.php create mode 100644 src/inertia/src/DevTools/EntryStore.php create mode 100644 src/inertia/src/DevTools/Http/Authorize.php create mode 100644 src/inertia/src/DevTools/Http/EntriesController.php create mode 100644 src/inertia/src/DevTools/Http/PreserveFlashData.php create mode 100644 src/inertia/src/DevTools/Http/PreventPreviousUrlTracking.php create mode 100644 src/inertia/src/DevTools/IncomingEntryBuilder.php create mode 100644 src/inertia/src/DevTools/PropClassifier.php create mode 100644 src/inertia/src/DevTools/RedactsSensitiveData.php create mode 100644 src/inertia/src/DevTools/RequestAttribute.php create mode 100644 src/inertia/src/DevTools/RequestRecorder.php create mode 100644 src/inertia/src/DevTools/SourceLocator.php create mode 100644 tests/Inertia/DevTools/AuthorizeGateTest.php create mode 100644 tests/Inertia/DevTools/AuthorizeMiddlewareTest.php create mode 100644 tests/Inertia/DevTools/AuthorizeTest.php create mode 100644 tests/Inertia/DevTools/CollectorIntegrationTest.php create mode 100644 tests/Inertia/DevTools/CoroutineIsolationTest.php create mode 100644 tests/Inertia/DevTools/DevToolsTest.php create mode 100644 tests/Inertia/DevTools/EntriesRepositoryTest.php create mode 100644 tests/Inertia/DevTools/EntryStoreTest.php create mode 100644 tests/Inertia/DevTools/FlashDataTest.php create mode 100644 tests/Inertia/DevTools/HttpEndpointsTest.php create mode 100644 tests/Inertia/DevTools/IncomingEntryBuilderMatrixTest.php create mode 100644 tests/Inertia/DevTools/IncomingEntryBuilderTest.php create mode 100644 tests/Inertia/DevTools/InteractsWithDevToolsStorage.php create mode 100644 tests/Inertia/DevTools/MiddlewareDevToolsDisabledTest.php create mode 100644 tests/Inertia/DevTools/MiddlewareDevToolsTest.php create mode 100644 tests/Inertia/DevTools/PropClassifierTest.php create mode 100644 tests/Inertia/DevTools/RecorderResilienceTest.php create mode 100644 tests/Inertia/DevTools/RedactsSensitiveDataTest.php create mode 100644 tests/Inertia/Fixtures/DevToolsRootViewMiddleware.php create mode 100644 tests/Inertia/Fixtures/devtools-app.blade.php diff --git a/src/docs/frontend.md b/src/docs/frontend.md index ee205f6b5..e56f11c11 100644 --- a/src/docs/frontend.md +++ b/src/docs/frontend.md @@ -112,6 +112,35 @@ As you can see, Inertia allows you to leverage the full power of React, Svelte, If you're concerned about diving into Inertia because your application requires server-side rendering, don't worry. Inertia offers [server-side rendering support](https://inertiajs.com/server-side-rendering). And, when deploying your application via [SonicStack](https://sonicstack.io), it's a breeze to ensure that Inertia's server-side rendering process is always running. +#### DevTools + +[Inertia DevTools](https://inertiajs.com/docs/devtools) is a browser extension that records every Inertia visit and displays it in a dedicated DevTools panel, showing which props each visit returned, whether they were deferred or merged, the request and response headers, and which route and controller handled it. There is no separate package to install: Hypervel's Inertia adapter includes the recorder, so you only need the browser extension and the Inertia client-side adapter at `^3.6`. + +The recorder is enabled automatically in your local environment. You may set the `INERTIA_DEVTOOLS_ENABLED` environment variable to override that default: + +```ini +INERTIA_DEVTOOLS_ENABLED=false +``` + +Entries are written to `storage/inertia-devtools` and pruned automatically, and sensitive keys and headers are redacted before an entry is stored. You may adjust the storage, redaction, and excluded paths under the `devtools` key of your application's `config/inertia.php` configuration file. + +To allow access outside your local environment, define a gate and reference it using the `INERTIA_DEVTOOLS_GATE` environment variable: + +```php +use Hypervel\Support\Facades\Gate; + +Gate::define('viewInertiaDevTools', function ($user) { + return $user->isAdmin(); +}); +``` + +```ini +INERTIA_DEVTOOLS_ENABLED=true +INERTIA_DEVTOOLS_GATE=viewInertiaDevTools +``` + +Your local environment is always allowed, so a gate can never lock you out of DevTools while you work locally. + ### Starter Kits diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php index e25b77084..5ac3dee21 100644 --- a/src/inertia/config/inertia.php +++ b/src/inertia/config/inertia.php @@ -2,6 +2,8 @@ declare(strict_types=1); +$devtoolsEnabled = env('INERTIA_DEVTOOLS_ENABLED'); + return [ /* |-------------------------------------------------------------------------- @@ -159,4 +161,61 @@ 'history' => [ 'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false), ], + + /* + |-------------------------------------------------------------------------- + | DevTools + |-------------------------------------------------------------------------- + | + | Records one entry per request to disk so the DevTools Chrome extension may + | read it back over HTTP. When `enabled` is null, recording is limited to + | your local environment. Omitted DevTools members use the defaults shown + | below. See https://inertiajs.com/docs/devtools for the gate and storage + | options. + | + */ + + 'devtools' => [ + 'enabled' => $devtoolsEnabled === null ? null : (bool) $devtoolsEnabled, + + 'except' => ['telescope*', 'horizon*', '_inertia/devtools*'], + + 'storage' => [ + 'path' => storage_path('inertia-devtools'), + + 'ttl' => (int) env('INERTIA_DEVTOOLS_TTL_HOURS', 24), + + 'prune_interval' => (int) env('INERTIA_DEVTOOLS_PRUNE_INTERVAL_SECONDS', 300), + + 'limit' => (int) env('INERTIA_DEVTOOLS_LIMIT', 100), + ], + + 'middleware' => ['web'], + + 'gate' => env('INERTIA_DEVTOOLS_GATE'), + + 'redact' => [ + 'keys' => [ + 'password', + 'password_confirmation', + 'current_password', + 'token', + '_token', + 'access_token', + 'refresh_token', + 'secret', + 'client_secret', + 'api_key', + ], + + 'headers' => [ + 'cookie', + 'set-cookie', + 'authorization', + 'proxy-authorization', + 'x-xsrf-token', + 'x-csrf-token', + ], + ], + ], ]; diff --git a/src/inertia/src/DevTools/Collector.php b/src/inertia/src/DevTools/Collector.php new file mode 100644 index 000000000..e58ca4681 --- /dev/null +++ b/src/inertia/src/DevTools/Collector.php @@ -0,0 +1,321 @@ +> */ + protected array $props = []; + + /** @var null|array{file: string, line: int} */ + protected ?array $renderSource = null; + + /** @var array */ + protected array $shareSources = []; + + /** @var null|array{name: null|string, uri: string, method: string} */ + protected ?array $route = null; + + /** @var array */ + protected array $sharedKeys = []; + + protected ?string $routeAction = null; + + /** @var null|array{file: string, line: int} */ + protected ?array $actionSource = null; + + protected ?string $componentPath = null; + + /** + * The resolved page props, kept nested so values may be plucked per prop path. + * + * @var array + */ + protected array $resolvedProps = []; + + /** + * Create a new collector instance. + */ + public function __construct(protected string $component, protected SourceLocator $sourceLocator) + { + } + + /** + * Set the source location of the render call. + */ + public function setRenderSource(?string $file, ?int $line): void + { + if ($file !== null && $line !== null) { + $this->renderSource = ['file' => $file, 'line' => $line]; + } + } + + /** + * Set the source locations of the shared prop keys. + * + * @param array $sources + */ + public function setShareSources(array $sources): void + { + $this->shareSources = $sources; + } + + /** + * Set the route that rendered the page. + */ + public function setRoute(?string $name, string $uri, string $method): void + { + $this->route = [ + 'name' => $name, + 'uri' => $uri, + 'method' => $method, + ]; + } + + /** + * Set which top-level prop keys came from shared props. + * + * @param array $keys + */ + public function setSharedKeys(array $keys): void + { + $this->sharedKeys = $keys; + } + + /** + * Record a prop's Inertia wrapper type, defer group, and extended metadata. + */ + public function addProp( + string $path, + ?PropType $inertiaType = null, + ?string $deferGroup = null, + bool $reset = false, + bool $once = false, + ?string $mergeDirection = null, + bool $deepMerge = false, + bool $rescued = false, + ): void { + $this->props[$path] = [ + 'shared' => in_array($path, $this->sharedKeys, true), + 'inertiaType' => $inertiaType?->value, + ]; + + if ($deferGroup !== null) { + $this->props[$path]['deferGroup'] = $deferGroup; + } + + if (isset($this->shareSources[$path])) { + $this->props[$path]['shareSource'] = $this->shareSources[$path]; + } + + if ($reset) { + $this->props[$path]['reset'] = true; + } + + if ($once) { + $this->props[$path]['once'] = true; + } + + if ($mergeDirection !== null) { + $this->props[$path]['mergeDirection'] = $mergeDirection; + } + + if ($deepMerge) { + $this->props[$path]['deepMerge'] = true; + } + + if ($rescued) { + $this->props[$path]['rescued'] = true; + } + } + + /** + * Store the route action and resolve its source location. + */ + public function setRouteAction(?string $action, mixed $uses = null): void + { + if ($action === null) { + return; + } + + $this->routeAction = $action; + + $this->actionSource = $this->sourceLocator->resolveActionSource($action, $uses); + } + + /** + * Store the resolved file path of the frontend component. + */ + public function setComponentPath(?string $path): void + { + if ($path !== null) { + $this->componentPath = $path; + } + } + + /** + * Store the resolved page props. Values are plucked per prop path when the entry + * is built, so no work is spent flattening props that get pruned. + * + * @param array $props + */ + public function setResolvedProps(array $props): void + { + $this->resolvedProps = $props; + } + + /** + * Scan the render source file to find the line number of each non-shared prop key. + */ + protected function resolveRenderPropLines(): void + { + if ($this->renderSource === null) { + return; + } + + $renderProps = collect($this->props) + ->reject(fn (array $info): bool => $info['shared'] || isset($info['shareSource'])) + ->keys() + ->all(); + + if ($renderProps === []) { + return; + } + + foreach ($renderProps as $propKey) { + $line = $this->sourceLocator->findPropKeyLine( + $this->renderSource['file'], + $this->renderSource['line'], + (string) $propKey, + ); + + if ($line !== null) { + $this->props[$propKey]['renderSource'] = [ + 'file' => $this->renderSource['file'], + 'line' => $line, + ]; + } + } + } + + /** + * Drop deep prop paths that carry no devtools metadata. Every top-level prop is + * kept so nothing disappears from the tree; nested values are rendered from the + * recorded prop values rather than one metadata row per leaf. + * + * @return array> + */ + protected function pruneProps(): array + { + return collect($this->props) + ->filter(fn (array $meta, string $path): bool => ! str_contains($path, '.') || $this->propHasMetadata($meta)) + ->all(); + } + + /** + * Determine if the prop carries metadata beyond its default shape. + * + * @param array $meta + */ + protected function propHasMetadata(array $meta): bool + { + return $meta['shared'] === true + || $meta['inertiaType'] !== null + || count($meta) > 2; + } + + /** + * Pluck the value backing each metadata node. Nested objects are stored once under + * their prop path rather than duplicated as the parent object and every exploded + * child path (e.g. `auth.user` alongside `auth.user.id`, `auth.user.name`). + * + * @param array $paths + * @return array + */ + protected function extractPropValues(array $paths): array + { + if ($this->resolvedProps === []) { + return []; + } + + $normalized = $this->normalizePropValues($this->resolvedProps); + $values = []; + + foreach ($paths as $path) { + if (Arr::has($normalized, $path)) { + $values[$path] = Arr::get($normalized, $path); + } + } + + return $values; + } + + /** + * Cast the resolved props to plain arrays and scalars so recorded values match the + * JSON the client received. + * + * @param array $props + * @return array + */ + protected function normalizePropValues(array $props): array + { + $encoded = json_encode($props); + + if (! is_string($encoded)) { + return $props; + } + + return json_decode($encoded, true); + } + + /** + * Assemble the final devtools metadata array. + * + * @return array + */ + public function build(): array + { + $this->resolveRenderPropLines(); + + $props = $this->pruneProps(); + + $result = [ + 'schemaVersion' => 1, + 'props' => $props, + 'component' => $this->component, + ]; + + if ($this->renderSource !== null) { + $result['renderSource'] = $this->renderSource; + } + + if ($this->route !== null) { + $result['route'] = $this->route; + } + + if ($this->routeAction !== null) { + $result['route']['action'] = $this->routeAction; + } + + if ($this->actionSource !== null) { + $result['route']['actionSource'] = $this->actionSource; + } + + if ($this->componentPath !== null) { + $result['componentPath'] = $this->componentPath; + } + + $propValues = $this->extractPropValues(array_keys($props)); + + if ($propValues !== []) { + $result['propValues'] = $propValues; + } + + return $result; + } +} diff --git a/src/inertia/src/DevTools/Data/IncomingEntry.php b/src/inertia/src/DevTools/Data/IncomingEntry.php new file mode 100644 index 000000000..f4050364d --- /dev/null +++ b/src/inertia/src/DevTools/Data/IncomingEntry.php @@ -0,0 +1,96 @@ + */ + public array $http = ['requestHeaders' => [], 'responseHeaders' => [], 'requestBody' => null, 'responseBody' => null]; + + /** @var array */ + public array $props = []; + + /** @var array */ + public array $propValues = []; + + /** @var array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} */ + public array $route = ['name' => null, 'uri' => '', 'action' => null]; + + /** @var null|array{file: string, line: int} */ + public ?array $renderSource = null; + + public ?string $componentPath = null; + + /** + * Create a new incoming entry instance. + */ + public function __construct(?string $id = null) + { + $this->id = $id ?? (string) Str::ulid(); + $this->utime = microtime(true); + $this->timestamp = CarbonImmutable::createFromTimestampMs((int) ($this->utime * 1000), 'UTC')->format('Y-m-d\TH:i:s.v\Z'); + } + + /** + * Get the entry as a storable array. + * + * @return array + */ + public function toArray(): array + { + return [ + '__meta' => [ + 'id' => $this->id, + 'tabUuid' => $this->tabUuid, + 'batchId' => $this->batchId, + 'timestamp' => $this->timestamp, + 'utime' => $this->utime, + 'method' => $this->method, + 'url' => $this->url, + 'component' => $this->component, + 'requestType' => $this->requestType->value, + 'status' => $this->status, + 'redirectLocation' => $this->redirectLocation, + 'serverTimingMs' => $this->serverTimingMs, + 'visitId' => $this->visitId, + ], + 'http' => $this->http, + 'props' => $this->props, + 'propValues' => $this->propValues, + 'route' => $this->route, + 'renderSource' => $this->renderSource, + 'componentPath' => $this->componentPath, + ]; + } +} diff --git a/src/inertia/src/DevTools/Data/PropType.php b/src/inertia/src/DevTools/Data/PropType.php new file mode 100644 index 000000000..b25fa5e92 --- /dev/null +++ b/src/inertia/src/DevTools/Data/PropType.php @@ -0,0 +1,15 @@ + + */ + public const array DEFAULT_EXCEPT = ['telescope*', 'horizon*', '_inertia/devtools*']; + + /** + * The prop and body keys redacted when the configuration omits them. + * + * @var array + */ + public const array DEFAULT_REDACT_KEYS = [ + 'password', + 'password_confirmation', + 'current_password', + 'token', + '_token', + 'access_token', + 'refresh_token', + 'secret', + 'client_secret', + 'api_key', + ]; + + /** + * The headers redacted when the configuration omits them. + * + * @var array + */ + public const array DEFAULT_REDACT_HEADERS = [ + 'cookie', + 'set-cookie', + 'authorization', + 'proxy-authorization', + 'x-xsrf-token', + 'x-csrf-token', + ]; + + /** + * Determine if DevTools recording is enabled. + */ + public static function enabled(): bool + { + $configured = config('inertia.devtools.enabled'); + + if ($configured === null) { + return app()->environment('local'); + } + + return (bool) $configured; + } + + /** + * The recorder to report the request lifecycle to, or null when nothing should be + * recorded. Pass the request wherever one is in hand so excluded paths skip the work. + */ + public static function recorder(?Request $request = null): ?RequestRecorder + { + return static::enabledForRequest($request) ? app(RequestRecorder::class) : null; + } + + /** + * Whether the given request should be recorded, defaulting to the current one so callers + * without a request in scope do not have to resolve it themselves. + */ + public static function enabledForRequest(?Request $request = null): bool + { + if (! static::enabled()) { + return false; + } + + // Read without the typed config helper: a misconfigured value would throw, and this + // runs inside the app's own request, where recording must never be the thing that + // breaks the response. + $patterns = array_values(array_filter(Arr::wrap(config('inertia.devtools.except', self::DEFAULT_EXCEPT)), 'is_string')); + + return $patterns === [] || ! ($request ?? request())->is(...$patterns); + } +} diff --git a/src/inertia/src/DevTools/DevToolsHeader.php b/src/inertia/src/DevTools/DevToolsHeader.php new file mode 100644 index 000000000..0c3b4e983 --- /dev/null +++ b/src/inertia/src/DevTools/DevToolsHeader.php @@ -0,0 +1,69 @@ +header($header); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/inertia/src/DevTools/DevToolsServiceProvider.php b/src/inertia/src/DevTools/DevToolsServiceProvider.php new file mode 100644 index 000000000..8592c40f8 --- /dev/null +++ b/src/inertia/src/DevTools/DevToolsServiceProvider.php @@ -0,0 +1,82 @@ +app->scoped(EntryStore::class, fn (): EntryStore => new EntryStore); + + $this->app->scoped(SourceLocator::class, fn (): SourceLocator => new SourceLocator); + + // Scoped rather than auto-singleton: the builder holds the request's source locator. + $this->app->scoped(IncomingEntryBuilder::class); + + $this->app->singleton(EntriesRepository::class, function (): EntriesRepository { + return new EntriesRepository( + path: config()->string('inertia.devtools.storage.path', storage_path('inertia-devtools')), + autoPruneHours: config()->integer('inertia.devtools.storage.ttl', 24), + ); + }); + + // Scoped: the recorder holds per-request collection state across the lifecycle + // callbacks. It self-disables (every method no-ops) when devtools is off. + $this->app->scoped(RequestRecorder::class, fn (): RequestRecorder => new RequestRecorder); + } + + /** + * Boot the service provider. + */ + public function boot(): void + { + if (! DevTools::enabled()) { + return; + } + + // Only requests are recorded, so the entry is flushed once the request has been handled, + // before the response is sent, so the extension can fetch it when the headers arrive. + $this->app->make('events')->listen(RequestHandled::class, function (): void { + $this->app->make(EntryStore::class)->flush($this->app->make(EntriesRepository::class)); + }); + + $middleware = [ + PreventPreviousUrlTracking::class, + ...$this->routeMiddleware(), + PreserveFlashData::class, + Authorize::class, + ]; + + Route::middleware($middleware) + ->prefix('_inertia/devtools') + ->group(function (): void { + Route::get('entries', [EntriesController::class, 'index']); + Route::get('entries/{id}', [EntriesController::class, 'show']); + }); + } + + /** + * The middleware the entry endpoints run before they are authorized. Defaults to the + * `web` group so the gate may authorize the user from the session it starts. + * + * @return array + */ + protected function routeMiddleware(): array + { + return Arr::wrap($this->app->make('config')->get('inertia.devtools.middleware', ['web'])); + } +} diff --git a/src/inertia/src/DevTools/EntriesRepository.php b/src/inertia/src/DevTools/EntriesRepository.php new file mode 100644 index 000000000..bdea341f2 --- /dev/null +++ b/src/inertia/src/DevTools/EntriesRepository.php @@ -0,0 +1,438 @@ + $data + */ + public function save(string $id, array $data): void + { + if (! $this->isValidEntryId($id)) { + throw new InvalidArgumentException('Invalid Inertia DevTools entry id.'); + } + + $encoded = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $this->ensureDirectory(); + // Filesystem::replace writes to a temp file and renames it into place, so an interrupted + // write may never leave a half-written entry: readers see the previous file or the new one. + $this->files->replace($this->filePath($id), $encoded); + $this->writeIndexMeta($id, $this->normalizeIndexMeta($data['__meta'] ?? [])); + } + + /** + * Get the payload of the given entry. + * + * @return null|array + */ + public function get(string $id): ?array + { + if (! $this->isValidEntryId($id)) { + return null; + } + + $file = $this->filePath($id); + + if (! $this->files->exists($file)) { + return null; + } + + $decoded = $this->readJsonFile($file); + + return is_array($decoded) ? $decoded : null; + } + + /** + * All recorded entry metadata, newest first. + * + * @return array> + */ + public function all(): array + { + return collect($this->readIndex()) + ->sortByDesc(fn (array $meta): string => (string) ($meta['id'] ?? '')) + ->values() + ->all(); + } + + /** + * Delete the entries recorded more than the given number of hours ago. + */ + public function prune(int $hours): void + { + if (! $this->files->isDirectory($this->path)) { + return; + } + + $cutoff = microtime(true) - ($hours * 3600); + + $expired = collect($this->readIndex()) + ->filter(fn (array $meta): bool => ($meta['utime'] ?? 0) < $cutoff) + ->keys() + ->all(); + + $this->deleteEntries($expired); + } + + /** + * Delete all but the given number of newest entries for the tab. + */ + public function enforceTabLimit(string $tabUuid, int $limit): void + { + if ($limit <= 0 || ! $this->files->isDirectory($this->path)) { + return; + } + + // Keep the newest $limit entries for the tab; delete everything older. + $drop = collect($this->readIndex()) + ->where('tabUuid', $tabUuid) + ->sortByDesc('id') + ->slice($limit) + ->keys() + ->all(); + + $this->deleteEntries($drop); + } + + /** + * Prune expired entries when the prune interval has elapsed. + */ + public function pruneIfDue(): void + { + $intervalSeconds = config()->integer('inertia.devtools.storage.prune_interval', 300); + + if ($intervalSeconds <= 0) { + $this->prune($this->autoPruneHours); + + return; + } + + $this->ensureDirectory(); + + $lastPrunedAt = $this->readLastPrunedAt(); + + if ($lastPrunedAt !== null && (time() - $lastPrunedAt) < $intervalSeconds) { + return; + } + + $this->prune($this->autoPruneHours); + $this->writeLastPrunedAt(time()); + } + + /** + * Ensure the storage directory and its .gitignore exist. + */ + protected function ensureDirectory(): void + { + $this->files->ensureDirectoryExists($this->path, 0700); + + $gitignore = $this->path . DIRECTORY_SEPARATOR . '.gitignore'; + + if ($this->files->missing($gitignore)) { + $this->files->put($gitignore, "*\n"); + } + } + + /** + * Get the file path of the given entry. + */ + protected function filePath(string $id): string + { + return $this->path . DIRECTORY_SEPARATOR . $id . '.json'; + } + + /** + * Determine if the given entry id is valid. + */ + protected function isValidEntryId(string $id): bool + { + return Str::isUlid($id); + } + + /** + * Get the path of the index file. + */ + protected function indexPath(): string + { + return $this->path . DIRECTORY_SEPARATOR . self::INDEX_FILE; + } + + /** + * Get the path of the file recording the last prune time. + */ + protected function lastPrunePath(): string + { + return $this->path . DIRECTORY_SEPARATOR . self::LAST_PRUNE_FILE; + } + + /** + * Normalize an entry's __meta for storage in the index. The full meta is kept so + * the list endpoint may filter and render without reading every entry file; the + * fields the storage layer relies on are coerced to known types. + * + * @param array $meta + * @return array + */ + protected function normalizeIndexMeta(array $meta): array + { + return array_merge($meta, [ + 'id' => (string) ($meta['id'] ?? ''), + 'tabUuid' => isset($meta['tabUuid']) && is_string($meta['tabUuid']) && $meta['tabUuid'] !== '' ? $meta['tabUuid'] : null, + 'utime' => isset($meta['utime']) ? (float) $meta['utime'] : microtime(true), + ]); + } + + /** + * Read the entry index, rebuilding it when it is missing or corrupt. + * + * @return array> + */ + protected function readIndex(): array + { + // A missing or corrupt index (e.g. a write interrupted mid-rewrite) must not lose the + // entries: the per-entry files are the source of truth, so rebuild the index from them. + if ($this->files->missing($this->indexPath())) { + return $this->rebuildIndexFromFiles(); + } + + $decoded = $this->readJsonFile($this->indexPath()); + + if (! is_array($decoded)) { + return $this->rebuildIndexFromFiles(); + } + + return collect($decoded) + ->filter(fn (mixed $meta): bool => is_array($meta)) + ->map(fn (array $meta): array => $this->normalizeIndexMeta($meta)) + ->all(); + } + + /** + * Rebuild the index from the per-entry files. + * + * @return array> + */ + protected function rebuildIndexFromFiles(): array + { + $index = null; + + // Rebuild under the index lock, where mutateIndex() reseeds a missing or corrupt index + // from the entry files, so recovery cannot overwrite an entry saved since the read. + $this->mutateIndex(function (array $current) use (&$index): array { + return $index = $current; + }); + + return $index ?? $this->metaFromFiles(); + } + + /** + * Scan the per-entry files and build the index map straight from their `__meta`. + * + * @return array> + */ + protected function metaFromFiles(): array + { + return collect($this->jsonFiles()) + ->map(fn (string $file): ?array => $this->readMeta($file)) + ->filter(fn (?array $meta): bool => $meta !== null && (string) ($meta['id'] ?? '') !== '') + ->keyBy(fn (array $meta): string => (string) $meta['id']) + ->map(fn (array $meta): array => $this->normalizeIndexMeta($meta)) + ->all(); + } + + /** + * Write the given entry's metadata to the index. + * + * @param array $meta + */ + protected function writeIndexMeta(string $id, array $meta): void + { + $this->mutateIndex(function (array $index) use ($id, $meta): array { + $index[$id] = $this->normalizeIndexMeta($meta); + + return $index; + }); + } + + /** + * Apply the given change to the index while holding its exclusive lock. + * + * @param callable(array>): array> $mutator + */ + protected function mutateIndex(callable $mutator): void + { + $this->ensureDirectory(); + + $handle = @fopen($this->indexPath(), 'c+'); + + if ($handle === false) { + return; + } + + try { + if (! flock($handle, LOCK_EX)) { + return; + } + + $contents = stream_get_contents($handle); + $decoded = is_string($contents) && $contents !== '' ? json_decode($contents, true) : null; + // A missing, empty or corrupt on-disk index would otherwise be treated as empty, so this + // rewrite would drop every prior entry's meta. Reseed from the entry files before applying + // the change. + $index = is_array($decoded) ? $decoded : $this->metaFromFiles(); + $index = $mutator($index); + + rewind($handle); + ftruncate($handle, 0); + fwrite($handle, (string) json_encode($index, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + fflush($handle); + flock($handle, LOCK_UN); + } finally { + fclose($handle); + } + } + + /** + * Delete entry files and remove them from the index in a single rewrite. + * + * @param array $ids + */ + protected function deleteEntries(array $ids): void + { + if ($ids === []) { + return; + } + + foreach ($ids as $id) { + if ($this->isValidEntryId($id)) { + $this->files->delete($this->filePath($id)); + } + } + + $this->mutateIndex(function (array $index) use ($ids): array { + foreach ($ids as $id) { + unset($index[$id]); + } + + return $index; + }); + } + + /** + * Read the metadata from the given entry file. + * + * @return null|array + */ + protected function readMeta(string $file): ?array + { + $decoded = $this->readJsonFile($file); + + if (! is_array($decoded) || ! isset($decoded['__meta']) || ! is_array($decoded['__meta'])) { + return null; + } + + return $decoded['__meta']; + } + + /** + * Read the time of the last prune. + */ + protected function readLastPrunedAt(): ?int + { + $contents = $this->readLockedTextFile($this->lastPrunePath()); + + if ($contents === null || ! ctype_digit(trim($contents))) { + return null; + } + + return (int) trim($contents); + } + + /** + * Record the time of the last prune. + */ + protected function writeLastPrunedAt(int $timestamp): void + { + $this->files->put($this->lastPrunePath(), (string) $timestamp, lock: true); + } + + /** + * Read and decode the given JSON file. + * + * @return null|array + */ + protected function readJsonFile(string $file): ?array + { + $contents = $this->readLockedTextFile($file); + + if ($contents === null) { + return null; + } + + $decoded = json_decode($contents, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * Read the given file under a shared lock. + */ + protected function readLockedTextFile(string $file): ?string + { + if ($this->files->missing($file)) { + return null; + } + + try { + // Filesystem::get with a lock takes a shared (LOCK_SH) read lock. + return $this->files->get($file, lock: true); + } catch (Throwable) { + return null; + } + } + + /** + * Get the paths of the per-entry JSON files. + * + * @return iterable + */ + protected function jsonFiles(): iterable + { + if (! $this->files->isDirectory($this->path)) { + return; + } + + foreach ($this->files->files($this->path) as $file) { + if ($file->getExtension() !== 'json' || $file->getFilename() === self::INDEX_FILE) { + continue; + } + + yield $file->getPathname(); + } + } +} diff --git a/src/inertia/src/DevTools/EntryStore.php b/src/inertia/src/DevTools/EntryStore.php new file mode 100644 index 000000000..8b5bb2bf3 --- /dev/null +++ b/src/inertia/src/DevTools/EntryStore.php @@ -0,0 +1,109 @@ +pending = $entry; + } + + /** + * Get the pending entry. + */ + public function current(): ?IncomingEntry + { + return $this->pending; + } + + /** + * Discard the pending entry. + */ + public function reset(): void + { + $this->pending = null; + } + + /** + * Persist the pending entry and prune expired entries. + */ + public function flush(EntriesRepository $repo): void + { + $entry = $this->pending; + + if ($entry === null) { + return; + } + + $this->pending = null; + + if (static::$suppressedUntil !== null && microtime(true) < static::$suppressedUntil) { + return; + } + + try { + $repo->save($entry->id, $this->redactSensitiveStoragePayload($entry->toArray())); + + if ($entry->tabUuid !== null) { + $limit = config()->integer('inertia.devtools.storage.limit', 100); + + if ($limit > 0) { + $repo->enforceTabLimit($entry->tabUuid, $limit); + } + } + + // Pruning shares the storage directory, so a storage failure here must trip the + // same breaker rather than break the response. + $repo->pruneIfDue(); + + static::$suppressedUntil = null; + } catch (Throwable $e) { + if (static::$suppressedUntil === null) { + Log::warning('Inertia DevTools: failed to persist entry: ' . $e->getMessage()); + } + + static::$suppressedUntil = microtime(true) + self::SUPPRESS_SECONDS; + } + } + + /** + * Reset the circuit breaker so recording resumes immediately. + * + * Tests only. The breaker is shared by every request in the worker, so a reset during a + * request resumes writes for all of them while storage may still be failing. + */ + public static function resetCircuitBreaker(): void + { + static::$suppressedUntil = null; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::resetCircuitBreaker(); + } +} diff --git a/src/inertia/src/DevTools/Http/Authorize.php b/src/inertia/src/DevTools/Http/Authorize.php new file mode 100644 index 000000000..85953c90f --- /dev/null +++ b/src/inertia/src/DevTools/Http/Authorize.php @@ -0,0 +1,41 @@ +allows($request)) { + return $next($request); + } + + return response()->json(['message' => 'Forbidden.'], 403); + } + + /** + * The local environment is always allowed, since a failing gate would lock a developer + * out of their own devtools. Everywhere else access is granted only by the configured + * gate, which decides for the authenticated user. + */ + protected function allows(Request $request): bool + { + if (app()->environment('local')) { + return true; + } + + $gate = config('inertia.devtools.gate'); + + return is_string($gate) && $gate !== '' && Gate::forUser($request->user())->check($gate); + } +} diff --git a/src/inertia/src/DevTools/Http/EntriesController.php b/src/inertia/src/DevTools/Http/EntriesController.php new file mode 100644 index 000000000..a5c05cae9 --- /dev/null +++ b/src/inertia/src/DevTools/Http/EntriesController.php @@ -0,0 +1,70 @@ +> + */ + public function index(Request $request): Collection + { + $component = $request->query('component'); + $include = $this->typeList($request->query('type')); + $exclude = $this->typeList($request->query('exclude')); + $offset = max(0, (int) $request->query('offset', '0')); + $limit = $request->query('limit'); + + return collect($this->repository->all()) + ->when(is_string($component) && $component !== '', fn (Collection $entries): Collection => $entries->where('component', $component)) + ->when($include !== [], fn (Collection $entries): Collection => $entries->whereIn('requestType', $include)) + ->when($exclude !== [], fn (Collection $entries): Collection => $entries->whereNotIn('requestType', $exclude)) + ->when($offset > 0, fn (Collection $entries): Collection => $entries->slice($offset)) + ->when(is_numeric($limit), fn (Collection $entries): Collection => $entries->take(max(1, (int) $limit))) + ->values(); + } + + /** + * Show the given recorded entry. + * + * @return array + */ + public function show(string $id): array + { + if (! Str::isUlid($id)) { + abort(404, 'Not found.'); + } + + return $this->repository->get($id) ?? abort(404, 'Not found.'); + } + + /** + * Parse a comma-separated request-type query value into a list. + * + * @return array + */ + protected function typeList(mixed $value): array + { + if (! is_string($value) || $value === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode(',', $value)))); + } +} diff --git a/src/inertia/src/DevTools/Http/PreserveFlashData.php b/src/inertia/src/DevTools/Http/PreserveFlashData.php new file mode 100644 index 000000000..ed45c5658 --- /dev/null +++ b/src/inertia/src/DevTools/Http/PreserveFlashData.php @@ -0,0 +1,31 @@ +hasSession()) { + $request->session()->reflash(); + } + + return $response; + } +} diff --git a/src/inertia/src/DevTools/Http/PreventPreviousUrlTracking.php b/src/inertia/src/DevTools/Http/PreventPreviousUrlTracking.php new file mode 100644 index 000000000..eaa4ad9d0 --- /dev/null +++ b/src/inertia/src/DevTools/Http/PreventPreviousUrlTracking.php @@ -0,0 +1,27 @@ +headers->set('X-Requested-With', 'XMLHttpRequest'); + + return $next($request); + } +} diff --git a/src/inertia/src/DevTools/IncomingEntryBuilder.php b/src/inertia/src/DevTools/IncomingEntryBuilder.php new file mode 100644 index 000000000..6d9647afc --- /dev/null +++ b/src/inertia/src/DevTools/IncomingEntryBuilder.php @@ -0,0 +1,587 @@ +tabUuid = DevToolsHeader::read($request, DevToolsHeader::DEVTOOLS_TAB); + $entry->batchId = $batchId; + $entry->visitId = DevToolsHeader::read($request, DevToolsHeader::DEVTOOLS_VISIT); + $entry->method = $request->getMethod(); + $entry->url = $request->fullUrl(); + $entry->status = $response->getStatusCode(); + $entry->requestType = $this->resolveRequestType($request, $response, $isPrefetch); + $entry->redirectLocation = $this->resolveRedirectLocation($response); + $entry->serverTimingMs = $this->elapsedMs($request); + + $entry->http = [ + 'requestHeaders' => $this->redactHeaders($request->headers->all()), + 'responseHeaders' => $this->redactHeaders($response->headers->all()), + 'requestBody' => $this->captureRequestBody($request), + 'responseBody' => $this->captureResponseBody($request, $response), + ]; + + $this->mergeCollectorPayload($entry, $request); + + if ($this->routeIsEmpty($entry->route)) { + $entry->route = $this->routeFromRequest($request) ?? $entry->route; + } + + if ($entry->renderSource === null) { + $entry->renderSource = $this->renderSourceFromRoute($request); + } + + return $entry; + } + + /** + * Merge the collector payload recorded while rendering into the entry. + */ + protected function mergeCollectorPayload(IncomingEntry $entry, Request $request): void + { + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + if (! is_array($payload)) { + return; + } + + $entry->component = $this->stringValue($payload, 'component') ?? $entry->component; + $entry->props = $this->arrayValue($payload, 'props') ?? $entry->props; + $entry->componentPath = $this->stringValue($payload, 'componentPath') ?? $entry->componentPath; + + $propValues = $this->arrayValue($payload, 'propValues'); + + if ($propValues !== null) { + $entry->propValues = $this->sanitizeForJson($this->redactPropValues($propValues)); + } + + $entry->route = $this->routePayload($payload) ?? $entry->route; + $entry->renderSource = $this->renderSourcePayload($payload) ?? $entry->renderSource; + } + + /** + * Redact the recorded prop values. + * + * Nested props are recorded under their dotted path, so a value is redacted when any + * segment of its path is a sensitive key. + * + * @param array $propValues + * @return array + */ + protected function redactPropValues(array $propValues): array + { + $keys = $this->normalizeSensitiveKeys($this->redactKeys()); + $redacted = $this->redact($propValues, $keys); + + foreach (array_keys($redacted) as $path) { + if (array_intersect(explode('.', strtolower((string) $path)), $keys) !== []) { + $redacted[$path] = self::REDACTED; + } + } + + return $redacted; + } + + /** + * Get the given payload value when it is a string. + * + * @param array $payload + */ + protected function stringValue(array $payload, string $key): ?string + { + $value = $payload[$key] ?? null; + + return is_string($value) ? $value : null; + } + + /** + * Get the given payload value when it is an array. + * + * @param array $payload + * @return null|array + */ + protected function arrayValue(array $payload, string $key): ?array + { + $value = $payload[$key] ?? null; + + return is_array($value) ? $value : null; + } + + /** + * Get the normalized route from the collector payload. + * + * @param array $payload + * @return null|array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} + */ + protected function routePayload(array $payload): ?array + { + $route = $this->arrayValue($payload, 'route'); + + if ($route === null) { + return null; + } + + $normalized = [ + 'name' => $route['name'] ?? null, + 'uri' => (string) ($route['uri'] ?? ''), + 'action' => $route['action'] ?? null, + ]; + + $actionSource = $this->arrayValue($route, 'actionSource'); + + if ($actionSource !== null) { + $normalized['actionSource'] = [ + 'file' => (string) ($actionSource['file'] ?? ''), + 'line' => (int) ($actionSource['line'] ?? 0), + ]; + } + + return $normalized; + } + + /** + * Get the normalized render source from the collector payload. + * + * @param array $payload + * @return null|array{file: string, line: int} + */ + protected function renderSourcePayload(array $payload): ?array + { + $renderSource = $this->arrayValue($payload, 'renderSource'); + + if ($renderSource === null) { + return null; + } + + return [ + 'file' => (string) ($renderSource['file'] ?? ''), + 'line' => (int) ($renderSource['line'] ?? 0), + ]; + } + + /** + * Resolve the kind of request the entry records. + */ + protected function resolveRequestType(Request $request, SymfonyResponse $response, ?bool $isPrefetch = null): RequestType + { + $isPrefetch ??= $request->prefetch(); + + if ($request->header(Header::PRECOGNITION)) { + return RequestType::Precognition; + } + + if (! $request->header(Header::INERTIA)) { + return $this->renderedInertiaPage($request) ? RequestType::Initial : RequestType::Http; + } + + if ($request->header(DevToolsHeader::DEVTOOLS_DEFERRED)) { + return RequestType::Deferred; + } + + if ($request->header(DevToolsHeader::DEVTOOLS_POLL)) { + return RequestType::Poll; + } + + if ($request->header(Header::PARTIAL_COMPONENT)) { + return RequestType::Partial; + } + + if ($isPrefetch) { + return RequestType::Prefetch; + } + + return RequestType::Navigate; + } + + /** + * A non-Inertia request that rendered an Inertia page (component present in the + * collector payload) is the app's initial page load. One that rendered no Inertia + * page is a plain HTTP request the app serves alongside Inertia. + */ + protected function renderedInertiaPage(Request $request): bool + { + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + return is_array($payload) + && isset($payload['component']) + && is_string($payload['component']) + && $payload['component'] !== ''; + } + + /** + * Resolve the location the response redirects to. + */ + protected function resolveRedirectLocation(SymfonyResponse $response): ?string + { + $inertiaLocation = $response->headers->get(Header::LOCATION); + + if (is_string($inertiaLocation) && $inertiaLocation !== '') { + return $inertiaLocation; + } + + $status = $response->getStatusCode(); + + if ($status < 300 || $status >= 400) { + return null; + } + + $location = $response->headers->get('Location'); + + return is_string($location) && $location !== '' ? $location : null; + } + + /** + * Capture the request body. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureRequestBody(Request $request): array + { + $writeMethod = in_array($request->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true); + + if ($writeMethod && ! $request->header(Header::INERTIA)) { + return ['status' => 'omitted', 'reason' => 'non-inertia-request']; + } + + $redactKeys = $this->redactKeys(); + + if ($request->isJson()) { + $body = (array) $request->json()->all(); + + return $body === [] ? ['status' => 'empty'] : $this->captureBodyValue($this->redact($body, $redactKeys)); + } + + $input = $request->all(); + + if ($input !== []) { + return $this->captureBodyValue($this->redact($this->summarizeUploads($input), $redactKeys)); + } + + return $this->captureBodyString($request->getContent() ?: null); + } + + /** + * Capture the response body. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureResponseBody(Request $request, SymfonyResponse $response): array + { + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + if (is_array($payload) && array_key_exists('responseBody', $payload)) { + return $this->captureInertiaResponseBody($payload['responseBody']); + } + + return $this->captureRawResponseBody($response); + } + + /** + * Capture the page object of an Inertia response. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureInertiaResponseBody(mixed $responseBody): array + { + if (is_string($responseBody)) { + return $this->captureBodyString($responseBody); + } + + if (is_array($responseBody)) { + return $this->captureBodyValue($this->redact($this->normalizeResponseBody($responseBody), $this->redactKeys())); + } + + if ($responseBody === null) { + return ['status' => 'empty']; + } + + return $this->captureBodyValue($responseBody); + } + + /** + * Cast the page object to the JSON the client received. Resolved props still hold live + * values here (a model, a date, the always-shared `errors` object), and the storage + * pass would replace those object leaves with a marker. + * + * @param array $responseBody + * @return array + */ + protected function normalizeResponseBody(array $responseBody): array + { + $encoded = json_encode($responseBody); + + if (! is_string($encoded)) { + return $responseBody; + } + + $decoded = json_decode($encoded, true); + + return is_array($decoded) ? $decoded : $responseBody; + } + + /** + * Capture the body of a non-Inertia response (plain JSON/text endpoints the app + * serves alongside Inertia). Binary, streamed, and oversized bodies are omitted. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureRawResponseBody(SymfonyResponse $response): array + { + $contentType = strtolower((string) $response->headers->get('Content-Type', '')); + + if (! $this->isTextualContentType($contentType)) { + return ['status' => 'omitted', 'reason' => 'non-textual']; + } + + $content = $response->getContent(); + + if ($content === false) { + return ['status' => 'omitted', 'reason' => 'streamed']; + } + + if ($content === '') { + return ['status' => 'empty']; + } + + if (strlen($content) > self::RAW_BODY_LIMIT) { + return ['status' => 'omitted', 'reason' => 'too-large']; + } + + if (str_contains($contentType, 'json')) { + $decoded = json_decode($content, true); + + if (is_array($decoded)) { + return $this->captureBodyValue($this->redact($decoded, $this->redactKeys())); + } + } + + return $this->captureBodyString($content); + } + + /** + * Determine if the content type is textual. + */ + protected function isTextualContentType(string $contentType): bool + { + foreach (['json', 'text/', 'xml', 'javascript'] as $needle) { + if (str_contains($contentType, $needle)) { + return true; + } + } + + return false; + } + + /** + * Capture the given body value when it can be encoded. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + protected function captureBodyValue(mixed $value): array + { + if (! $this->isEncodable($value)) { + return ['status' => 'omitted', 'reason' => 'unserializable']; + } + + return ['status' => 'present', 'value' => $value]; + } + + /** + * Confirm a value survives JSON encoding with the flags the repository persists it + * with. An un-encodable value would otherwise throw at save and drop the entry. + */ + protected function isEncodable(mixed $value): bool + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !== false; + } + + /** + * Replace individual leaf values that cannot be JSON encoded with a marker, keeping + * the surrounding structure intact. Prop values are shared verbatim by the app, so a + * single non-UTF-8 attribute must not fail the whole entry at save. + * + * @param array $data + * @return array + */ + protected function sanitizeForJson(array $data): array + { + return collect($data) + ->map(function (mixed $value): mixed { + if (is_array($value)) { + return $this->sanitizeForJson($value); + } + + return $this->isEncodable($value) ? $value : '[UNSERIALIZABLE]'; + }) + ->all(); + } + + /** + * Capture the given body string when it is valid UTF-8. + * + * @return array{status: string, value?: string, reason?: string} + */ + protected function captureBodyString(?string $body): array + { + if ($body === null || $body === '') { + return ['status' => 'empty']; + } + + if (! mb_check_encoding($body, 'UTF-8')) { + return ['status' => 'omitted', 'reason' => 'binary']; + } + + return ['status' => 'present', 'value' => $body]; + } + + /** + * Replace uploaded files with a lightweight summary so binary contents are never + * serialized into an entry. + * + * @param array $input + * @return array + */ + protected function summarizeUploads(array $input): array + { + return collect($input) + ->map(function (mixed $value): mixed { + if ($value instanceof UploadedFile) { + return $this->summarizeUpload($value); + } + + return is_array($value) ? $this->summarizeUploads($value) : $value; + }) + ->all(); + } + + /** + * Summarize the given uploaded file. + * + * @return array{name: ?string, size: null|false|int, mimeType: ?string} + */ + protected function summarizeUpload(UploadedFile $file): array + { + return [ + 'name' => $file->getClientOriginalName(), + 'size' => $file->isValid() ? $file->getSize() : null, + 'mimeType' => $file->getClientMimeType(), + ]; + } + + /** + * Resolve the render source for route-defined renders (Route::inertia), which have + * no user-space render call site. The definition location is captured onto the route + * defaults by the `Route::inertia()` macro when the route is registered. + * + * @return null|array{file: string, line: int} + */ + protected function renderSourceFromRoute(Request $request): ?array + { + $source = $request->route()?->defaults[DevTools::RENDER_SOURCE_KEY] ?? null; + + if (! is_array($source) || ! isset($source['file'], $source['line'])) { + return null; + } + + return ['file' => (string) $source['file'], 'line' => (int) $source['line']]; + } + + /** + * Determine if the route carries no information. + * + * @param array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} $route + */ + protected function routeIsEmpty(array $route): bool + { + return ($route['name'] ?? null) === null + && $route['uri'] === '' + && ($route['action'] ?? null) === null + && ! array_key_exists('actionSource', $route); + } + + /** + * Get the normalized route from the request. + * + * @return null|array{name: ?string, uri: string, action: ?string, actionSource?: array{file: string, line: int}} + */ + protected function routeFromRequest(Request $request): ?array + { + $route = $request->route(); + + if ($route === null) { + return null; + } + + $normalized = [ + 'name' => $route->getName(), + 'uri' => '/' . ltrim($route->uri(), '/'), + 'action' => $route->getActionName(), + ]; + + $actionSource = $this->sourceLocator->resolveActionSource( + $route->getActionName(), + $route->getAction('uses') + ); + + if ($actionSource !== null) { + $normalized['actionSource'] = $actionSource; + } + + return $normalized; + } + + /** + * Get the milliseconds elapsed since the request started. + */ + protected function elapsedMs(Request $request): float + { + $start = $request->attributes->get(RequestAttribute::START); + + if (! is_int($start)) { + return 0.0; + } + + return (hrtime(true) - $start) / 1_000_000; + } + + /** + * Get the keys whose values are redacted. + * + * @return array + */ + protected function redactKeys(): array + { + return config()->array('inertia.devtools.redact.keys', DevTools::DEFAULT_REDACT_KEYS); + } +} diff --git a/src/inertia/src/DevTools/PropClassifier.php b/src/inertia/src/DevTools/PropClassifier.php new file mode 100644 index 000000000..e46ecf3c1 --- /dev/null +++ b/src/inertia/src/DevTools/PropClassifier.php @@ -0,0 +1,133 @@ +isDeferredRequest($request); + + return [ + 'inertiaType' => $this->classifyInertiaWrapper($prop, $isDeferredDelivery), + 'deferGroup' => $this->deferGroup($prop, $isDeferredDelivery), + 'reset' => in_array($path, $this->parseDevToolsHeader($request, Header::RESET), true), + 'once' => $prop instanceof Onceable && $prop->shouldResolveOnce(), + 'mergeDirection' => $this->mergeDirection($prop), + 'deepMerge' => $this->isDeepMerge($prop), + ]; + } + + /** + * Determine if the request loads deferred props. + */ + protected function isDeferredRequest(Request $request): bool + { + return (bool) $request->header(DevToolsHeader::DEVTOOLS_DEFERRED); + } + + /** + * The defer group applies to a genuinely deferred DeferProp, and to other deferrable props + * (e.g. ScrollProp) as before. A DeferProp reloaded outside a deferred request carries none. + */ + protected function deferGroup(mixed $prop, bool $isDeferredDelivery): ?string + { + if (! $prop instanceof Deferrable || ! $prop->shouldDefer()) { + return null; + } + + if ($prop instanceof DeferProp && ! $isDeferredDelivery) { + return null; + } + + return $prop->group(); + } + + /** + * A prop is a deep merge when it deep-merges nested data (`->deepMerge()`) or matches + * array items on a key (`->matchOn()`) to upsert them rather than blindly appending. + */ + protected function isDeepMerge(mixed $prop): bool + { + return $prop instanceof Mergeable && ($prop->shouldDeepMerge() || count($prop->matchesOn()) > 0); + } + + /** + * Resolve how a merge/scroll prop combines with existing client data. Direction is read + * from the prop wrapper (not the page-object arrays) so it survives deep merges, which + * the page object records only under `deepMergeProps` without a direction. + */ + protected function mergeDirection(mixed $prop): ?string + { + if (! $prop instanceof Mergeable || ! $prop->shouldMerge()) { + return null; + } + + $prependsNested = count($prop->prependsAtPaths()) > 0; + $appendsNested = count($prop->appendsAtPaths()) > 0; + + if ($prop->prependsAtRoot() || ($prependsNested && ! $appendsNested)) { + return 'prepend'; + } + + return 'append'; + } + + /** + * Classify an Inertia prop wrapper instance into a stable token the extension + * renders as a type pill. + */ + protected function classifyInertiaWrapper(mixed $prop, bool $isDeferredDelivery): ?PropType + { + return match (true) { + $prop instanceof AlwaysProp => PropType::Always, + $prop instanceof DeferProp => $isDeferredDelivery ? PropType::Defer : null, + $prop instanceof OptionalProp => PropType::Optional, + $prop instanceof MergeProp => PropType::Merge, + $prop instanceof ScrollProp => PropType::Scroll, + $prop instanceof OnceProp => PropType::Once, + default => null, + }; + } + + /** + * Parse a comma-separated request header into a list. + * + * @return array + */ + protected function parseDevToolsHeader(Request $request, string $key): array + { + return array_filter( + explode(',', (string) $request->header($key, '')), + fn (string $value): bool => $value !== '', + ); + } +} diff --git a/src/inertia/src/DevTools/RedactsSensitiveData.php b/src/inertia/src/DevTools/RedactsSensitiveData.php new file mode 100644 index 000000000..02e1d6a79 --- /dev/null +++ b/src/inertia/src/DevTools/RedactsSensitiveData.php @@ -0,0 +1,253 @@ + $data + * @param array $keys + * @return array + */ + protected function redact(array $data, array $keys): array + { + $lowered = $this->normalizeSensitiveKeys($keys); + + if ($lowered === []) { + return $data; + } + + return $this->redactRecursive($data, $lowered); + } + + /** + * Final storage pass for entry payloads. Earlier builders redact known request + * surfaces, but this keeps persisted entries private if a future collector path + * adds sensitive data before save. + * + * @param array $payload + * @return array + */ + protected function redactSensitiveStoragePayload(array $payload): array + { + $keys = config()->array('inertia.devtools.redact.keys', DevTools::DEFAULT_REDACT_KEYS); + + $payload = $this->redact($payload, $keys); + $payload = $this->redactUrls($payload, $keys); + $payload = $this->redactHeaderBags($payload); + + return $this->sanitizeForJsonEncoding($payload); + } + + /** + * Redact the values of the given lowercase keys at every depth. + * + * @param array $data + * @param array $loweredKeys + * @return array + */ + protected function redactRecursive(array $data, array $loweredKeys): array + { + return collect($data) + ->map(function (mixed $value, int|string $key) use ($loweredKeys): mixed { + if (is_string($key) && in_array(strtolower($key), $loweredKeys, true)) { + return self::REDACTED; + } + + return is_array($value) ? $this->redactRecursive($value, $loweredKeys) : $value; + }) + ->all(); + } + + /** + * Flatten the given headers, redacting the sensitive ones. + * + * @param array $headers + * @return array + */ + protected function redactHeaders(array $headers): array + { + $sensitive = $this->normalizeSensitiveKeys(config()->array('inertia.devtools.redact.headers', DevTools::DEFAULT_REDACT_HEADERS)); + + return collect($headers) + ->map(function (mixed $value, int|string $name) use ($sensitive): string { + if (is_string($name) && in_array(strtolower($name), $sensitive, true)) { + return self::REDACTED; + } + + return is_array($value) ? implode(', ', $value) : (string) $value; + }) + ->all(); + } + + /** + * Normalize the configured sensitive keys to unique lowercase strings. + * + * @param array $keys + * @return array + */ + protected function normalizeSensitiveKeys(array $keys): array + { + $normalized = []; + + foreach ($keys as $key) { + if (! is_string($key) || $key === '') { + continue; + } + + $normalized[] = strtolower($key); + } + + return array_values(array_unique($normalized)); + } + + /** + * Redact sensitive query parameters from the URLs in the data. + * + * @param array $data + * @param array $keys + * @return array + */ + protected function redactUrls(array $data, array $keys): array + { + $lowered = $this->normalizeSensitiveKeys($keys); + + if ($lowered === []) { + return $data; + } + + return collect($data) + ->map(function (mixed $value, int|string $key) use ($lowered): mixed { + if (is_array($value)) { + return $this->redactUrls($value, $lowered); + } + + if (is_string($value) && is_string($key) && in_array(strtolower($key), ['url', 'redirectlocation'], true)) { + return $this->redactUrl($value, $lowered); + } + + return $value; + }) + ->all(); + } + + /** + * Redact sensitive query parameters, preserving scheme, host, path and fragment. The URL + * may be relative or malformed, so an unparseable value is returned unchanged rather + * than throwing: redaction must never break the recorder. + * + * @param array $keys + */ + protected function redactUrl(string $url, array $keys): string + { + $lowered = $this->normalizeSensitiveKeys($keys); + + if ($lowered === [] || ! str_contains($url, '?')) { + return $url; + } + + try { + $uri = Uri::of($url); + $params = $uri->query()->all(); + + if ($params === []) { + return $url; + } + + return $uri->withQuery($this->redactQueryParameters($params, $lowered), merge: false)->value(); + } catch (Throwable) { + return $url; + } + } + + /** + * Walk the parsed query parameters and replace the value of any sensitive key with the + * redacted marker. A sensitive key redacts its whole subtree; nested keys (e.g. from + * `filter[secret]`) are matched at their own depth. + * + * @param array $params + * @param array $loweredKeys + * @return array + */ + protected function redactQueryParameters(array $params, array $loweredKeys): array + { + $result = []; + + foreach ($params as $key => $value) { + if (is_string($key) && in_array(strtolower($key), $loweredKeys, true)) { + $result[$key] = self::REDACTED; + + continue; + } + + $result[$key] = is_array($value) ? $this->redactQueryParameters($value, $loweredKeys) : $value; + } + + return $result; + } + + /** + * Redact the sensitive headers in every header bag in the data. + * + * @param array $data + * @return array + */ + protected function redactHeaderBags(array $data): array + { + return collect($data) + ->map(function (mixed $value, int|string $key): mixed { + if (! is_array($value)) { + return $value; + } + + if (is_string($key) && in_array(strtolower($key), ['requestheaders', 'responseheaders'], true)) { + return $this->redactHeaders($value); + } + + return $this->redactHeaderBags($value); + }) + ->all(); + } + + /** + * Replace leaf values that cannot be stored as JSON with a marker. + * + * @param array $data + * @return array + */ + protected function sanitizeForJsonEncoding(array $data): array + { + return collect($data) + ->map(function (mixed $value): mixed { + if (is_array($value)) { + return $this->sanitizeForJsonEncoding($value); + } + + return $this->isJsonEncodable($value) ? $value : self::UNSERIALIZABLE; + }) + ->all(); + } + + /** + * Determine if the value can be stored as JSON. + */ + protected function isJsonEncodable(mixed $value): bool + { + if (is_object($value) || is_resource($value)) { + return false; + } + + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !== false; + } +} diff --git a/src/inertia/src/DevTools/RequestAttribute.php b/src/inertia/src/DevTools/RequestAttribute.php new file mode 100644 index 000000000..a533b8d44 --- /dev/null +++ b/src/inertia/src/DevTools/RequestAttribute.php @@ -0,0 +1,25 @@ +attributes) during + * the devtools request lifecycle. Unlike DevToolsHeader, these never leave the process: + * they carry state between the recorder, the collector, and the entry builder. + */ +class RequestAttribute +{ + /** + * High-resolution start time (hrtime) stamped when the request begins, used to + * compute the entry's server timing. + */ + public const string START = 'inertia_devtools_start'; + + /** + * The collector payload (component, props, propValues, route, sources) captured + * during rendering and merged into the entry. + */ + public const string PAYLOAD = 'inertia_devtools_payload'; +} diff --git a/src/inertia/src/DevTools/RequestRecorder.php b/src/inertia/src/DevTools/RequestRecorder.php new file mode 100644 index 000000000..f908073b8 --- /dev/null +++ b/src/inertia/src/DevTools/RequestRecorder.php @@ -0,0 +1,361 @@ + + */ + protected array $shareSources = []; + + protected ?Collector $collector = null; + + /** + * Stamp the request start time. + */ + public function requestStarted(Request $request): void + { + if (! DevTools::enabled()) { + return; + } + + $request->attributes->set(RequestAttribute::START, hrtime(true)); + } + + /** + * Scan the share() method source to resolve per-key line numbers. + * + * @param array $shared + */ + public function sharedPropsResolved(object $middleware, array $shared): void + { + if (! DevTools::enabledForRequest()) { + return; + } + + $reflection = new ReflectionMethod($middleware, 'share'); + $locator = app(SourceLocator::class); + $fallback = $locator->shareSourceFallback($reflection); + + if ($fallback === null) { + return; + } + + foreach (array_keys($shared) as $key) { + $key = (string) $key; + + $this->setShareSource([$key], $locator->resolveShareSource($reflection, $key) ?? $fallback); + } + } + + /** + * Capture the call site of a share() call and associate it with the given prop keys. + * + * @param array $keys + */ + public function propsShared(array $keys): void + { + if (! DevTools::enabled()) { + return; + } + + $locator = app(SourceLocator::class); + $source = $locator->captureCallerSource(); + + if ($source === null) { + return; + } + + foreach ($keys as $key) { + $key = (string) $key; + + $this->shareSources[$key] = [ + 'file' => $source['file'], + 'line' => $locator->findPropKeyLine($source['file'], $source['line'], $key) ?? $source['line'], + ]; + } + } + + /** + * Start collecting the page being rendered. + * + * @param array $sharedProps + */ + public function pageRendering(string $component, Response $response, array $sharedProps): void + { + if (! DevTools::enabled()) { + return; + } + + $locator = app(SourceLocator::class); + $renderSource = $locator->captureCallerSource(); + $collector = new Collector($component, $locator); + + if ($renderSource !== null) { + $collector->setRenderSource($renderSource['file'], $renderSource['line']); + } + + $collector->setShareSources($this->shareSources); + $collector->setSharedKeys($this->topLevelSharedKeys($sharedProps)); + + $this->collector = $collector; + } + + /** + * Record a resolved prop. + */ + public function propResolved(string $path, mixed $prop): void + { + $this->recordProp($path, $prop); + } + + /** + * Record a deferred prop whose resolver threw but was rescued (`Inertia::defer(rescue: true)`). + * It is skipped from the response props before normal metadata collection, so it needs its + * own hook: capture the defer type/group and flag it rescued, with no resolved value. + */ + public function propRescued(string $path, mixed $prop): void + { + $this->recordProp($path, $prop, rescued: true); + } + + /** + * Classify the prop and add it to the collector. + */ + protected function recordProp(string $path, mixed $prop, bool $rescued = false): void + { + if ($this->collector === null) { + return; + } + + $meta = app(PropClassifier::class)->classifyResolved($path, $prop, app(Request::class)); + + $this->collector->addProp( + $path, + $meta['inertiaType'], + $meta['deferGroup'], + $meta['reset'], + $meta['once'], + $meta['mergeDirection'], + $meta['deepMerge'], + rescued: $rescued, + ); + } + + /** + * Store the collected payload for the rendered page on the request. + * + * @param array $page + * @param array $resolvedProps + */ + public function pageRendered(Request $request, array $page, array $resolvedProps): void + { + if ($this->collector === null) { + return; + } + + $collector = $this->collector; + + if ($route = $request->route()) { + $collector->setRoute( + $route->getName(), + '/' . ltrim($route->uri(), '/'), + $request->method(), + ); + + $collector->setRouteAction($route->getActionName(), $route->getAction('uses')); + } + + try { + $collector->setComponentPath(App::make('inertia.view-finder')->find($page['component'])); + } catch (Throwable) { + } + + $collector->setResolvedProps($resolvedProps); + + $payload = $collector->build(); + $payload['responseBody'] = $page; + + $request->attributes->set(RequestAttribute::PAYLOAD, $payload); + } + + /** + * Record the response sent for the request. + */ + public function respondedWith(Request $request, SymfonyResponse $response): void + { + if (! DevTools::enabledForRequest($request)) { + return; + } + + try { + $this->recordResponse($request, $response); + } catch (Throwable) { + // Recording is a passive observer: a malformed request, an unserializable + // prop, or a misconfigured redact list must never turn the user's response + // into a 500, so the failure is swallowed and the entry dropped. + } + } + + /** + * Stamp the DevTools headers on the response and record its entry. + */ + protected function recordResponse(Request $request, SymfonyResponse $response): void + { + $id = (string) Str::ulid(); + $isPrefetch = $request->prefetch(); + [$batchId, $parentOut] = $this->resolveLineage($request, $id); + + $basePath = $request->getBaseUrl(); + + $response->headers->set(DevToolsHeader::DEVTOOLS_ID, $id); + $response->headers->set(DevToolsHeader::DEVTOOLS_OUTGOING_PARENT, $parentOut); + + if ($basePath !== '') { + $response->headers->set(DevToolsHeader::DEVTOOLS_BASE_PATH, $basePath); + } + + if ($this->isInitialHtmlResponse($request, $response)) { + $this->injectDevToolsIdTag($response, $id, $basePath); + } + + $entry = app(IncomingEntryBuilder::class)->build($request, $response, $id, $batchId, $isPrefetch); + + app(EntryStore::class)->record($entry); + } + + /** + * Set the source location for the given shared prop keys. + * + * @param array $keys + * @param array{file: string, line: int} $source + */ + protected function setShareSource(array $keys, array $source): void + { + foreach ($keys as $key) { + $this->shareSources[(string) $key] = $source; + } + } + + /** + * Compute the top-level keys of the currently-shared props for DevTools + * shared-vs-render annotations. + * + * @param array $sharedProps + * @return array + */ + protected function topLevelSharedKeys(array $sharedProps): array + { + return collect(array_keys($sharedProps)) + ->map(function (mixed $key): string { + $key = (string) $key; + + return str_contains($key, '.') ? strstr($key, '.', true) : $key; + }) + ->unique() + ->values() + ->all(); + } + + /** + * Whether this response is the initial load of an Inertia page. A plain HTML page is + * left alone: the extension reads the tag as devtools being enabled there, and would + * warn about a missing interceptor registry that was never going to appear. + */ + protected function isInitialHtmlResponse(Request $request, SymfonyResponse $response): bool + { + if ($request->header(Header::INERTIA)) { + return false; + } + + if (! $response->isOk()) { + return false; + } + + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + if (! is_array($payload) || ($payload['component'] ?? null) === null) { + return false; + } + + $contentType = (string) $response->headers->get('Content-Type', ''); + + return str_contains(strtolower($contentType), 'text/html'); + } + + /** + * Render the entry id into the page, along with the path the app is mounted on. A panel + * that attaches after the initial load has only the DOM to read, and the entry endpoint + * lives under that same path. + */ + protected function injectDevToolsIdTag(SymfonyResponse $response, string $id, string $basePath): void + { + $content = $response->getContent(); + + if (! is_string($content) || $content === '' || ! str_contains($content, '')) { + return; + } + + $basePathAttribute = $basePath === '' + ? '' + : ' data-inertia-devtools-base-path="' . e($basePath) . '"'; + + $tag = ''; + + // Hypervel's setContent() replaces the response's original value with the string it is + // given. For an Inertia page that value is the root view, which is where the testing + // assertions read the page object from, so it is put back. + $original = $response instanceof HttpResponse ? $response->original : null; + + $response->setContent(Str::replaceLast('', $tag . '', $content)); + + if ($response instanceof HttpResponse) { + $response->original = $original; + } + } + + /** + * Resolve the batch root id sent back to the client. + */ + protected function resolveOutgoingParentId(bool $isPrefetch, string $id, ?string $batchId): string + { + if ($isPrefetch) { + return $id; + } + + return $batchId ?? $id; + } + + /** + * Resolve the entry's batch id and the outgoing parent id. + * + * @return array{0: ?string, 1: string} + */ + protected function resolveLineage(Request $request, string $id): array + { + $isPrefetch = $request->prefetch(); + $batchId = $request->header(Header::INERTIA) + ? DevToolsHeader::read($request, DevToolsHeader::DEVTOOLS_INCOMING_PARENT) + : null; + + return [$batchId, $this->resolveOutgoingParentId($isPrefetch, $id, $batchId)]; + } +} diff --git a/src/inertia/src/DevTools/SourceLocator.php b/src/inertia/src/DevTools/SourceLocator.php new file mode 100644 index 000000000..92b1921e5 --- /dev/null +++ b/src/inertia/src/DevTools/SourceLocator.php @@ -0,0 +1,277 @@ +> */ + protected array $fileCache = []; + + /** + * Find the first caller frame outside of this package, the framework and vendor code. + * + * @return null|array{file: string, line: int} + */ + public function captureCallerSource(): ?array + { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20); + $srcDir = dirname(__DIR__) . DIRECTORY_SEPARATOR; + $frameworkDir = $this->frameworkDirectory(); + + foreach ($backtrace as $frame) { + if (($frame['class'] ?? null) === Controller::class && $frame['function'] === '__invoke') { + return null; + } + + if (! isset($frame['file'], $frame['line'])) { + continue; + } + + if (str_starts_with($frame['file'], $srcDir)) { + continue; + } + + if ($frameworkDir !== null && str_starts_with($frame['file'], $frameworkDir)) { + continue; + } + + if (str_contains($frame['file'], DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)) { + continue; + } + + return ['file' => $frame['file'], 'line' => $frame['line']]; + } + + return null; + } + + /** + * Get the directory holding the framework packages, when this package is installed among them. + * + * In the components repository or a path-linked checkout, framework frames such as facades, + * macros, the router and middleware are outside vendor and would be reported as the caller. + */ + protected function frameworkDirectory(): ?string + { + $directory = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR; + $facade = (new ReflectionClass(Facade::class))->getFileName(); + + return $facade !== false && str_starts_with($facade, $directory) ? $directory : null; + } + + /** + * Resolve the source location of a shared prop key by scanning the share() method. + * + * @return null|array{file: string, line: int} + */ + public function resolveShareSource(ReflectionMethod $reflection, string $key): ?array + { + $file = $reflection->getDeclaringClass()->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (! $file || ! $startLine || ! $endLine) { + return null; + } + + $line = $this->findPropKeyLine($file, $startLine, $key, $endLine); + + if ($line !== null) { + return ['file' => $file, 'line' => $line]; + } + + if (! $this->methodBodyContains($reflection, 'parent::share(')) { + return null; + } + + $parent = $reflection->getDeclaringClass()->getParentClass(); + + if ($parent === false || ! $parent->hasMethod('share')) { + return null; + } + + return $this->resolveShareSource($parent->getMethod('share'), $key); + } + + /** + * Determine if the method body contains the given text. + */ + public function methodBodyContains(ReflectionMethod $reflection, string $needle): bool + { + $file = $reflection->getDeclaringClass()->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (! $file || ! $startLine || ! $endLine) { + return false; + } + + $lines = $this->readSourceLines($file); + if ($lines === null) { + return false; + } + + for ($i = $startLine - 1; $i < min($endLine, count($lines)); ++$i) { + if (str_contains($lines[$i], $needle)) { + return true; + } + } + + return false; + } + + /** + * Resolve the first plausible line of a share() method body, used as a fallback + * when a specific prop key line cannot be found. + * + * @return null|array{file: string, line: int} + */ + public function shareSourceFallback(ReflectionMethod $reflection): ?array + { + $file = $reflection->getDeclaringClass()->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (! $file || ! $startLine || ! $endLine) { + return null; + } + + $lines = $this->readSourceLines($file); + if ($lines === null) { + return null; + } + + for ($i = $startLine - 1; $i < min($endLine, count($lines)); ++$i) { + if (str_contains($lines[$i], 'function') || str_contains($lines[$i], 'return [')) { + return ['file' => $file, 'line' => $i + 1]; + } + } + + return ['file' => $file, 'line' => $startLine]; + } + + /** + * Read the lines of the given source file. + * + * @return null|array + */ + public function readSourceLines(?string $file): ?array + { + if (! $file) { + return null; + } + + if (array_key_exists($file, $this->fileCache)) { + return $this->fileCache[$file]; + } + + if (! is_file($file) || ! is_readable($file)) { + return $this->fileCache[$file] = null; + } + + $lines = file($file); + + return $this->fileCache[$file] = ($lines === false ? null : $lines); + } + + /** + * Find the source line where a prop key is defined in an array literal. + */ + public function findPropKeyLine(string $file, int $startLine, string $key, ?int $endLine = null): ?int + { + $lines = $this->readSourceLines($file); + + if ($lines === null) { + return null; + } + + $maxScan = min($endLine ?? $startLine + 100, count($lines)); + $pattern = "/['\"]" . preg_quote($key, '/') . "['\"]\\s*=>/"; + + for ($i = $startLine - 1; $i < $maxScan; ++$i) { + if (preg_match($pattern, $lines[$i])) { + return $i + 1; + } + } + + return null; + } + + /** + * Resolve the route action source location when possible. + * + * @return null|array{file: string, line: int} + */ + public function resolveActionSource(?string $action, mixed $uses = null): ?array + { + if ($action === null) { + return null; + } + + try { + $reflection = self::reflectAction($action, $uses); + + if ($reflection === null) { + return null; + } + + $file = $reflection->getFileName(); + $line = $reflection->getStartLine(); + + if ($file && $line) { + return ['file' => $file, 'line' => $line]; + } + } catch (Throwable) { + return null; + } + + return null; + } + + /** + * Reflect a route action, covering closures, controller methods, array callables, + * and invokable controllers. + */ + protected static function reflectAction(string $action, mixed $uses): ?ReflectionFunctionAbstract + { + if ($uses instanceof Closure) { + return new ReflectionFunction($uses); + } + + if (is_array($uses) && count($uses) === 2) { + return new ReflectionMethod($uses[0], $uses[1]); + } + + if (str_contains($action, '@')) { + [$class, $method] = explode('@', $action, 2); + + return new ReflectionMethod($class, $method); + } + + if (is_object($uses) && method_exists($uses, '__invoke')) { + return new ReflectionMethod($uses, '__invoke'); + } + + if (class_exists($action) && method_exists($action, '__invoke')) { + return new ReflectionMethod($action, '__invoke'); + } + + return null; + } +} diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php index 134699199..b4b1ee49c 100644 --- a/src/inertia/src/InertiaServiceProvider.php +++ b/src/inertia/src/InertiaServiceProvider.php @@ -7,6 +7,9 @@ use Hypervel\Contracts\Http\Kernel as HttpKernelContract; use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; +use Hypervel\Inertia\DevTools\DevToolsServiceProvider; +use Hypervel\Inertia\DevTools\SourceLocator; use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Ssr\HttpGateway; use Hypervel\Inertia\Support\Header; @@ -42,6 +45,7 @@ public function register(): void $this->registerRouterMacro(); $this->registerTestingMacros(); $this->registerMiddleware(); + $this->app->register(DevToolsServiceProvider::class); $this->app->singleton('inertia.view-finder', function ($app) { $config = $app->make('config'); @@ -150,9 +154,19 @@ protected function registerRouterMacro(): void * @param array $props */ Router::macro('inertia', function ($uri, $component, $props = []) { - return $this->match(['GET', 'HEAD'], $uri, '\\' . Controller::class) + $route = $this->match(['GET', 'HEAD'], $uri, '\\' . Controller::class) ->defaults('component', $component) ->defaults('props', $props); + + if (DevTools::enabled()) { + $source = app(SourceLocator::class)->captureCallerSource(); + + if ($source !== null) { + $route->defaults(DevTools::RENDER_SOURCE_KEY, $source); + } + } + + return $route; }); } diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php index 38a257411..9c7b48338 100644 --- a/src/inertia/src/Middleware.php +++ b/src/inertia/src/Middleware.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\Ssr\ExcludesSsrPaths; use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Support\Header; @@ -125,11 +126,19 @@ public function urlResolver(): ?Closure */ public function handle(Request $request, Closure $next): Response { + $recorder = DevTools::recorder($request); + + $recorder?->requestStarted($request); + Inertia::version(function () use ($request) { return $this->version($request); }); - Inertia::share($this->share($request)); + $shared = $this->share($request); + + Inertia::share($shared); + + $recorder?->sharedPropsResolved($this, $shared); foreach ($this->shareOnce($request) as $key => $value) { if ($value instanceof OnceProp) { @@ -160,6 +169,8 @@ public function handle(Request $request, Closure $next): Response if (! $request->header(Header::INERTIA)) { $this->addInertiaVaryHeader($response); + $recorder?->respondedWith($request, $response); + return $response; } @@ -181,6 +192,8 @@ public function handle(Request $request, Closure $next): Response $this->addInertiaVaryHeader($response); + $recorder?->respondedWith($request, $response); + return $response; } diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php index 679b00b19..519f55df1 100644 --- a/src/inertia/src/PropsResolver.php +++ b/src/inertia/src/PropsResolver.php @@ -9,6 +9,8 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; +use Hypervel\Inertia\DevTools\RequestRecorder; use Hypervel\Inertia\Support\Header; use Hypervel\Support\Arr; use Hypervel\Support\Facades\App; @@ -129,6 +131,13 @@ class PropsResolver */ protected array $sharedPropKeys = []; + /** + * The devtools recorder, resolved only while recording is active. It stays null when + * devtools is disabled so the per-prop resolution loop never touches it, keeping the + * hot path free of recorder calls, container lookups, and prop classification. + */ + protected ?RequestRecorder $recorder = null; + /** * Create a new props resolver instance. */ @@ -143,6 +152,8 @@ public function __construct(Request $request, string $component) $this->except = $this->parseHeader(Header::PARTIAL_EXCEPT); $this->resetProps = $this->parseHeader(Header::RESET) ?? []; $this->loadedOnceProps = $this->parseHeader(Header::EXCEPT_ONCE_PROPS) ?? []; + + $this->recorder = DevTools::recorder($request); } /** @@ -268,6 +279,8 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW $value = $this->resolveValue($prop, $path, $props); if (in_array($path, $this->rescuedProps, true)) { + $this->recorder?->propRescued($path, $prop); + continue; } @@ -287,6 +300,7 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW } $this->collectMetadata($prop, $path); + $this->recorder?->propResolved($path, $prop); // When the resolved value is an array, we recurse into it. If the // original prop was not already an array (e.g. a closure that diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php index 84f1b7174..25323e1ed 100644 --- a/src/inertia/src/Response.php +++ b/src/inertia/src/Response.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; +use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\Support\Header; use Hypervel\Inertia\Support\SessionKey; use Hypervel\Support\Facades\App; @@ -187,6 +188,8 @@ public function toResponse(Request $request): SymfonyResponse $this->resolvePreserveFragment($request), ); + DevTools::recorder($request)?->pageRendered($request, $page, $resolvedProps); + if ($request->header(Header::INERTIA)) { return new JsonResponse($page, 200, [Header::INERTIA => 'true']); } diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php index b74a0abea..481add6cc 100644 --- a/src/inertia/src/ResponseFactory.php +++ b/src/inertia/src/ResponseFactory.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; use Hypervel\Http\Request as HttpRequest; +use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\Ssr\DisablesSsr; use Hypervel\Inertia\Ssr\ExcludesSsrPaths; use Hypervel\Inertia\Ssr\Gateway; @@ -64,12 +65,16 @@ public function share(mixed $key, mixed $value = null): void if (is_array($key)) { $state->sharedProps = array_merge($state->sharedProps, $key); + DevTools::recorder()?->propsShared(array_keys($key)); } elseif ($key instanceof Arrayable) { - $state->sharedProps = array_merge($state->sharedProps, $key->toArray()); + $resolved = $key->toArray(); + $state->sharedProps = array_merge($state->sharedProps, $resolved); + DevTools::recorder()?->propsShared(array_keys($resolved)); } elseif ($key instanceof ProvidesInertiaProperties) { $state->sharedProps = array_merge($state->sharedProps, [$key]); } else { Arr::set($state->sharedProps, $key, $value); + DevTools::recorder()?->propsShared([(string) $key]); } } @@ -319,7 +324,7 @@ public function render(mixed $component, mixed $props = []): Response $state = $this->state(); - return new Response( + $response = new Response( $component, $state->sharedProps, $props, @@ -328,6 +333,10 @@ public function render(mixed $component, mixed $props = []): Response $state->encryptHistory ?? config()->boolean('inertia.history.encrypt', false), $state->urlResolver, ); + + DevTools::recorder()?->pageRendering($component, $response, $state->sharedProps); + + return $response; } /** diff --git a/src/inertia/src/Support/Header.php b/src/inertia/src/Support/Header.php index 70cde2de5..9cbd48ed4 100644 --- a/src/inertia/src/Support/Header.php +++ b/src/inertia/src/Support/Header.php @@ -36,6 +36,11 @@ class Header */ public const string PARTIAL_COMPONENT = 'X-Inertia-Partial-Component'; + /** + * Header for Hypervel Precognition validation requests. + */ + public const string PRECOGNITION = 'Precognition'; + /** * Header specifying which props to include in partial reloads. */ diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 86c1b3ab1..3bbdeb7b9 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -381,6 +381,7 @@ protected function flushImageState(): void */ protected function flushInertiaState(): void { + $this->callIfExists(\Hypervel\Inertia\DevTools\EntryStore::class, 'flushState'); $this->callIfExists(\Hypervel\Inertia\Middleware::class, 'flushState'); $this->callIfExists(\Hypervel\Inertia\Response::class, 'flushState'); $this->callIfExists(\Hypervel\Inertia\ResponseFactory::class, 'flushState'); diff --git a/tests/Inertia/DevTools/AuthorizeGateTest.php b/tests/Inertia/DevTools/AuthorizeGateTest.php new file mode 100644 index 000000000..416410912 --- /dev/null +++ b/tests/Inertia/DevTools/AuthorizeGateTest.php @@ -0,0 +1,110 @@ +make('config'); + + // The entry endpoints run the `web` middleware group, which encrypts cookies. + $config->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.gate', 'viewInertiaDevtools'); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + $this->app->instance('env', 'production'); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + public function testAConfiguredGateIsAuthoritativeOutsideTheLocalEnvironment(): void + { + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => true); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + $this->getJson('/_inertia/devtools/entries/' . $this->savedEntryId())->assertOk(); + } + + public function testAFailingGateDeniesAccess(): void + { + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => false); + + $this->getJson('/_inertia/devtools/entries')->assertForbidden(); + $this->getJson('/_inertia/devtools/entries/' . $this->savedEntryId())->assertForbidden(); + } + + public function testTheLocalEnvironmentIsAllowedEvenWhenTheGateFails(): void + { + $this->app->instance('env', 'local'); + + // Entries are a local development tool: a gate that fails locally, e.g. because the + // developer is not signed in, must not lock them out of their own devtools. + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => false); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + } + + public function testTheGateReceivesTheAuthenticatedUser(): void + { + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => $user?->getAuthIdentifier() === 42); + + $this->getJson('/_inertia/devtools/entries')->assertForbidden(); + + $this->actingAs(new GenericUser(['id' => 42])) + ->getJson('/_inertia/devtools/entries') + ->assertOk(); + } + + public function testTheGateRunsWithTheSessionStarted(): void + { + // Without the session the `web` group starts, a gate that authenticates the + // user could never allow anyone in. + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => request()->hasSession()); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + } + + /** + * Save an entry and return its id. + */ + protected function savedEntryId(): string + { + $id = (string) Str::ulid(); + + $this->repo->save($id, ['__meta' => ['id' => $id]]); + + return $id; + } +} diff --git a/tests/Inertia/DevTools/AuthorizeMiddlewareTest.php b/tests/Inertia/DevTools/AuthorizeMiddlewareTest.php new file mode 100644 index 000000000..2058524e4 --- /dev/null +++ b/tests/Inertia/DevTools/AuthorizeMiddlewareTest.php @@ -0,0 +1,63 @@ +make('config'); + + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.gate', 'viewInertiaDevtools'); + $config->set('inertia.devtools.middleware', [StartSession::class]); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + $this->app->instance('env', 'production'); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + public function testTheConfiguredMiddlewareReplacesTheGateDefault(): void + { + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => request()->hasSession()); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + } + + public function testAuthorizationStillRunsWhenTheMiddlewareIsConfigured(): void + { + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => false); + + $this->getJson('/_inertia/devtools/entries')->assertForbidden(); + } +} diff --git a/tests/Inertia/DevTools/AuthorizeTest.php b/tests/Inertia/DevTools/AuthorizeTest.php new file mode 100644 index 000000000..02eeae00d --- /dev/null +++ b/tests/Inertia/DevTools/AuthorizeTest.php @@ -0,0 +1,94 @@ +make('config'); + + // The entry endpoints run the `web` middleware group, which encrypts cookies. + $config->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.gate', null); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + public function testTheLocalEnvironmentIsAllowedWithoutAGate(): void + { + $this->environment('local'); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + $this->getJson('/_inertia/devtools/entries/' . $this->savedEntryId())->assertOk(); + } + + public function testOtherEnvironmentsAreDeniedWithoutAGate(): void + { + $this->environment('production'); + + $this->getJson('/_inertia/devtools/entries')->assertForbidden(); + $this->getJson('/_inertia/devtools/entries/' . $this->savedEntryId())->assertForbidden(); + } + + public function testPollingTheEntriesEndpointDoesNotBecomeThePreviousUrl(): void + { + $this->environment('local'); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + + // The extension polls these endpoints in the background. A recorded previous URL + // would become the target of the app's next `back()` redirect. + $this->assertNull($this->app->make('session')->previousUrl()); + } + + /** + * Switch the application environment. + */ + protected function environment(string $environment): void + { + $this->app->instance('env', $environment); + } + + /** + * Save an entry and return its id. + */ + protected function savedEntryId(): string + { + $id = (string) Str::ulid(); + + $this->repo->save($id, ['__meta' => ['id' => $id]]); + + return $id; + } +} diff --git a/tests/Inertia/DevTools/CollectorIntegrationTest.php b/tests/Inertia/DevTools/CollectorIntegrationTest.php new file mode 100644 index 000000000..7d09ab866 --- /dev/null +++ b/tests/Inertia/DevTools/CollectorIntegrationTest.php @@ -0,0 +1,500 @@ +make('config')->set('inertia.devtools.enabled', true); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + /** + * Assert a resolved source location points at a line containing the given text. + * + * This avoids hardcoding line numbers: reformatting the file moves both the code and + * the resolved line together, so the assertion stays valid. + * + * @param array{file: string, line: int} $source + */ + private function assertSourceLineContains(array $source, string $needle): void + { + $lines = file($source['file']); + + $this->assertArrayHasKey($source['line'] - 1, $lines, "No line {$source['line']} in {$source['file']}"); + $this->assertStringContainsString($needle, $lines[$source['line'] - 1]); + } + + public function testCollectorPayloadReachesRecorderAndDoesNotLeakIntoPageJson(): void + { + Route::middleware(Middleware::class) + ->get('/collector-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Alice'])) + ->name('users.index'); + + $response = $this->get('/collector-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $response->assertOk(); + + $json = $response->json(); + $this->assertArrayNotHasKey('devtools', $json); + + $this->app->make(EntryStore::class)->flush($this->repo); + $this->assertCount(1, $this->recordedEntries()); + + $entry = $this->latestRecordedEntry(); + + $this->assertSame('Users/Index', $entry['__meta']['component']); + $this->assertSame('users.index', $entry['route']['name']); + $this->assertSame('/collector-route', $entry['route']['uri']); + $this->assertSame('present', $entry['http']['responseBody']['status']); + $this->assertSame('Users/Index', $entry['http']['responseBody']['value']['component']); + $this->assertSame('Alice', $entry['http']['responseBody']['value']['props']['name']); + } + + public function testPropsArePopulatedWithInertiaMetadata(): void + { + Route::middleware(Middleware::class)->get('/props-route', fn (): Response => Inertia::render('Users/Index', [ + 'name' => 'Alice', + 'tags' => ['a', 'b'], + 'auth' => ['user' => ['id' => 1, 'name' => 'John']], + 'lazy' => Inertia::optional(fn (): string => 'never resolved'), + 'eager' => Inertia::always(fn (): string => 'always there'), + ])); + + $this->get('/props-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + $props = $entry['props']; + + $this->assertArrayHasKey('name', $props); + $this->assertArrayNotHasKey('phpType', $props['name']); + $this->assertFalse($props['name']['shared']); + $this->assertSame('Alice', $entry['propValues']['name']); + + $this->assertArrayHasKey('tags', $props); + $this->assertArrayNotHasKey('phpType', $props['tags']); + $this->assertArrayNotHasKey('count', $props['tags']); + $this->assertArrayNotHasKey('model', $props['tags']); + $this->assertSame(['a', 'b'], $entry['propValues']['tags']); + $this->assertArrayNotHasKey('tags.0', $props); + $this->assertArrayNotHasKey('tags.1', $props); + $this->assertArrayNotHasKey('tags.0', $entry['propValues']); + $this->assertArrayNotHasKey('tags.1', $entry['propValues']); + + $this->assertSame(['user' => ['id' => 1, 'name' => 'John']], $entry['propValues']['auth']); + $this->assertArrayNotHasKey('auth.user', $entry['propValues']); + $this->assertArrayNotHasKey('auth.user.id', $entry['propValues']); + $this->assertArrayNotHasKey('auth.user.name', $entry['propValues']); + + $this->assertArrayHasKey('eager', $props); + $this->assertSame('always', $props['eager']['inertiaType']); + $this->assertSame('always there', $entry['propValues']['eager']); + + $this->assertArrayNotHasKey('lazy', $props); + } + + public function testPropValuesAreRedacted(): void + { + Route::middleware(Middleware::class)->get('/redact-route', fn (): Response => Inertia::render('Users/Index', [ + 'token' => 'super-secret', + 'auth' => ['user' => ['name' => 'John', 'api_key' => 'xyz']], + ])); + + $this->get('/redact-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $propValues = $this->latestRecordedEntry()['propValues']; + + $this->assertSame('[REDACTED]', $propValues['token']); + $this->assertSame('John', $propValues['auth']['user']['name']); + $this->assertSame('[REDACTED]', $propValues['auth']['user']['api_key']); + } + + public function testNestedPropValuesAreRedactedByTheirPath(): void + { + Route::middleware(Middleware::class)->get('/nested-redact-route', fn (): Response => Inertia::render('Users/Index', [ + 'auth' => ['token' => Inertia::always('nested-secret'), 'name' => Inertia::always('John')], + 'secret' => ['hint' => Inertia::always('ancestor-secret')], + ])); + + $this->get('/nested-redact-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $propValues = $this->latestRecordedEntry()['propValues']; + + $this->assertSame('[REDACTED]', $propValues['auth.token']); + $this->assertSame('John', $propValues['auth.name']); + $this->assertSame('[REDACTED]', $propValues['secret.hint']); + $this->assertSame('[REDACTED]', $propValues['secret']); + } + + public function testMergeDirectionAndDeepMergeAreRecorded(): void + { + Route::middleware(Middleware::class)->get('/merge-route', fn (): Response => Inertia::render('Users/Index', [ + 'appended' => Inertia::merge(['a']), + 'prepended' => Inertia::merge(['b'])->prepend(), + 'deepAppended' => Inertia::merge(['c' => 1])->deepMerge(), + 'deepPrepended' => Inertia::merge(['d' => 1])->deepMerge()->prepend(), + 'matched' => Inertia::merge([['id' => 1]])->matchOn('id'), + ])); + + $this->get('/merge-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $props = $this->latestRecordedEntry()['props']; + + $this->assertSame('merge', $props['appended']['inertiaType']); + $this->assertSame('append', $props['appended']['mergeDirection']); + $this->assertArrayNotHasKey('deepMerge', $props['appended']); + + $this->assertSame('prepend', $props['prepended']['mergeDirection']); + $this->assertArrayNotHasKey('deepMerge', $props['prepended']); + + $this->assertSame('append', $props['deepAppended']['mergeDirection']); + $this->assertTrue($props['deepAppended']['deepMerge']); + + $this->assertSame('prepend', $props['deepPrepended']['mergeDirection']); + $this->assertTrue($props['deepPrepended']['deepMerge']); + + // matchOn() upserts by key, so it reads as a deep merge in the panel. + $this->assertSame('append', $props['matched']['mergeDirection']); + $this->assertTrue($props['matched']['deepMerge']); + } + + public function testDeferredPropReloadedOutsideADeferredRequestReadsAsRegular(): void + { + Route::middleware(Middleware::class)->get('/defer-reload-route', fn (): Response => Inertia::render('Users/Index', [ + 'lazy' => Inertia::defer(fn (): string => 'loaded', 'groupA'), + ])); + + // Manual partial reload (no devtools-deferred header): the DeferProp is delivered like a + // regular partial prop, so it carries no defer type or group. + $this->get('/defer-reload-route', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '', + 'X-Inertia-Partial-Component' => 'Users/Index', + 'X-Inertia-Partial-Data' => 'lazy', + ]); + $this->app->make(EntryStore::class)->flush($this->repo); + + $lazy = $this->latestRecordedEntry()['props']['lazy']; + $this->assertNull($lazy['inertiaType']); + $this->assertArrayNotHasKey('deferGroup', $lazy); + + // Deferred auto-load (devtools-deferred header): the prop reads as deferred with its group. + $this->get('/defer-reload-route', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '', + 'X-Inertia-Partial-Component' => 'Users/Index', + 'X-Inertia-Partial-Data' => 'lazy', + 'X-Inertia-Devtools-Deferred' => '1', + ]); + $this->app->make(EntryStore::class)->flush($this->repo); + + $lazy = $this->latestRecordedEntry()['props']['lazy']; + $this->assertSame('defer', $lazy['inertiaType']); + $this->assertSame('groupA', $lazy['deferGroup']); + } + + public function testRescuedDeferredPropIsFlagged(): void + { + Route::middleware(Middleware::class)->get('/rescue-route', fn (): Response => Inertia::render('Users/Index', [ + 'flaky' => Inertia::defer(fn (): never => throw new RuntimeException('boom'), rescue: true), + ])); + + $this->get('/rescue-route', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '', + 'X-Inertia-Partial-Component' => 'Users/Index', + 'X-Inertia-Partial-Data' => 'flaky', + 'X-Inertia-Devtools-Deferred' => '1', + ]); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + + $this->assertSame('defer', $entry['props']['flaky']['inertiaType']); + $this->assertTrue($entry['props']['flaky']['rescued']); + $this->assertArrayNotHasKey('flaky', $entry['propValues'] ?? []); + } + + public function testShareSourcesPopulateWhenShareIsCalled(): void + { + Inertia::share('flash', 'hello'); + + Route::middleware(Middleware::class)->get('/share-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Alice'])); + + $this->get('/share-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + + $this->assertTrue($entry['props']['flash']['shared']); + $this->assertArrayHasKey('file', $entry['props']['flash']['shareSource']); + $this->assertArrayHasKey('line', $entry['props']['flash']['shareSource']); + } + + public function testShareSourcesResolveEachArrayKeyLine(): void + { + Inertia::share([ + 'first_shared' => 'one', + 'second_shared' => 'two', + ]); + + Route::middleware(Middleware::class)->get('/share-lines-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Jane'])); + + $this->get('/share-lines-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + $first = $entry['props']['first_shared']['shareSource']; + $second = $entry['props']['second_shared']['shareSource']; + + $this->assertSame(__FILE__, $first['file']); + $this->assertSourceLineContains($first, "'first_shared' => 'one'"); + $this->assertSame(__FILE__, $second['file']); + $this->assertSourceLineContains($second, "'second_shared' => 'two'"); + $this->assertNotSame($first['line'], $second['line']); + } + + public function testMiddlewareShareSourcesResolveEachShareMethodKeyLine(): void + { + Route::middleware(DevToolsSharedSourceMiddleware::class)->get('/middleware-share-lines-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Jane'])); + + $this->get('/middleware-share-lines-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + $first = $entry['props']['middleware_first_shared']['shareSource']; + $second = $entry['props']['middleware_second_shared']['shareSource']; + + $this->assertSame(__FILE__, $first['file']); + $this->assertSourceLineContains($first, "'middleware_first_shared' => 'one'"); + $this->assertSame(__FILE__, $second['file']); + $this->assertSourceLineContains($second, "'middleware_second_shared' => 'two'"); + $this->assertNotSame($first['line'], $second['line']); + } + + public function testMiddlewareShareSourcesResolveParentShareMethodKeys(): void + { + Route::middleware(DevToolsChildSharedSourceMiddleware::class)->get('/middleware-parent-share-lines-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Jane'])); + + $this->get('/middleware-parent-share-lines-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + $parent = $entry['props']['parent_shared']['shareSource']; + $child = $entry['props']['child_shared']['shareSource']; + + $this->assertSame(__FILE__, $parent['file']); + $this->assertSourceLineContains($parent, "'parent_shared' => 'parent'"); + $this->assertSame(__FILE__, $child['file']); + $this->assertSourceLineContains($child, "'child_shared' => 'child'"); + $this->assertNotSame($parent['line'], $child['line']); + } + + public function testRenderSourceResolvesToTheRenderCallSite(): void + { + Route::middleware(Middleware::class) + ->get('/render-source-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Alice'])); + + $this->get('/render-source-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + + $this->assertSame(__FILE__, $entry['renderSource']['file']); + $this->assertSourceLineContains($entry['renderSource'], "Inertia::render('Users/Index'"); + } + + public function testRenderSourceResolvesToTheRouteDefinitionForRouteDefinedInertiaRenders(): void + { + Route::inertia('/route-inertia', 'Users/Index', ['name' => 'Alice'])->middleware(Middleware::class); + + $this->get('/route-inertia', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + + $this->assertSame(__FILE__, $entry['renderSource']['file']); + $this->assertSourceLineContains($entry['renderSource'], "Route::inertia('/route-inertia'"); + } + + public function testActionSourceResolvesForInvokableControllers(): void + { + Route::middleware(Middleware::class)->get('/invokable-route', DevToolsInvokableController::class); + + $this->get('/invokable-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + + $this->assertSame(__FILE__, $entry['route']['actionSource']['file']); + $this->assertSourceLineContains($entry['route']['actionSource'], 'function __invoke'); + } + + public function testRenderSourceResolvesThroughTheInertiaHelper(): void + { + Route::middleware(Middleware::class) + ->get('/helper-render-route', fn (): Response => inertia('Users/Index', ['name' => 'Alice'])); + + $this->get('/helper-render-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $entry = $this->latestRecordedEntry(); + + $this->assertSame(__FILE__, $entry['renderSource']['file']); + $this->assertSourceLineContains($entry['renderSource'], "inertia('Users/Index'"); + } + + public function testOnceSharedMiddlewareKeysDoNotRecordFrameworkSources(): void + { + Route::middleware(DevToolsOnceSharedMiddleware::class) + ->get('/once-shared-route', fn (): Response => Inertia::render('Users/Index', ['name' => 'Alice'])); + + $this->get('/once-shared-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => ''])->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $source = $this->latestRecordedEntry()['props']['once_shared']['shareSource'] ?? null; + $frameworkDirectory = dirname((new ReflectionClass(Facade::class))->getFileName(), 4) . DIRECTORY_SEPARATOR; + + // The share call runs inside Inertia's middleware, so the frames above it belong to the + // framework's pipeline and middleware, which must not be reported as the share source. + $this->assertStringStartsNotWith($frameworkDirectory, $source['file'] ?? ''); + } + + public function testNumericPropKeysAreRecorded(): void + { + Route::middleware(Middleware::class) + ->get('/numeric-props-route', fn (): Response => Inertia::render('Users/Index', ['2024' => 'year'])); + + $response = $this->get('/numeric-props-route', ['X-Inertia' => 'true', 'X-Inertia-Version' => '']); + + $response->assertOk(); + + $this->app->make(EntryStore::class)->flush($this->repo); + + // Integer prop keys reach the collector's string-typed paths; recording must still match + // the props the client received. + $this->assertSame($response->json('props'), $this->latestRecordedEntry()['propValues']); + } +} + +class DevToolsOnceSharedMiddleware extends Middleware +{ + /** + * Define the props that are shared once. + */ + public function shareOnce(Request $request): array + { + return [ + 'once_shared' => fn (): string => 'once', + ]; + } +} + +class DevToolsInvokableController +{ + /** + * Render the users page. + */ + public function __invoke(): Response + { + return Inertia::render('Users/Index', ['name' => 'Alice']); + } +} + +class DevToolsSharedSourceMiddleware extends Middleware +{ + /** + * Define the props that are shared by default. + */ + public function share(Request $request): array + { + return array_merge(parent::share($request), [ + 'middleware_first_shared' => 'one', + 'middleware_second_shared' => 'two', + ]); + } +} + +class DevToolsParentSharedSourceMiddleware extends Middleware +{ + /** + * Define the props that are shared by default. + */ + public function share(Request $request): array + { + return [ + 'parent_shared' => 'parent', + ]; + } +} + +class DevToolsChildSharedSourceMiddleware extends DevToolsParentSharedSourceMiddleware +{ + /** + * Define the props that are shared by default. + */ + public function share(Request $request): array + { + return [ + ...parent::share($request), + 'child_shared' => 'child', + ]; + } +} diff --git a/tests/Inertia/DevTools/CoroutineIsolationTest.php b/tests/Inertia/DevTools/CoroutineIsolationTest.php new file mode 100644 index 000000000..f27c611b0 --- /dev/null +++ b/tests/Inertia/DevTools/CoroutineIsolationTest.php @@ -0,0 +1,93 @@ +make('config')->set('inertia.devtools.enabled', true); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + public function testEntriesRecordedByConcurrentRequestsAreFlushedWhenEachRequestIsHandled(): void + { + [$first, $second] = parallel([ + fn (): array => $this->recordAndHandle('Users/Index'), + fn (): array => $this->recordAndHandle('Posts/Index'), + ]); + + $this->assertEqualsCanonicalizing( + ['Users/Index', 'Posts/Index'], + array_column($this->repo->all(), 'component'), + ); + + // Each request records through its own store, entry builder and source locator. + foreach (['store', 'builder', 'locator'] as $service) { + $this->assertNotSame($first[$service], $second[$service]); + } + } + + /** + * Record an entry and finish the request in the current coroutine. + * + * @return array{store: EntryStore, builder: IncomingEntryBuilder, locator: SourceLocator} + */ + protected function recordAndHandle(string $component): array + { + $entry = new IncomingEntry; + $entry->component = $component; + + $store = $this->app->make(EntryStore::class); + $store->record($entry); + + // Let the other request record its entry before this one is handled. + usleep(5000); + + $this->app->make('events')->dispatch(new RequestHandled(Request::create('/'), new Response('ok'))); + + return [ + 'store' => $store, + 'builder' => $this->app->make(IncomingEntryBuilder::class), + 'locator' => $this->app->make(SourceLocator::class), + ]; + } +} diff --git a/tests/Inertia/DevTools/DevToolsTest.php b/tests/Inertia/DevTools/DevToolsTest.php new file mode 100644 index 000000000..c384edbcc --- /dev/null +++ b/tests/Inertia/DevTools/DevToolsTest.php @@ -0,0 +1,82 @@ +set('inertia.devtools.enabled', true); + $this->app->instance('env', 'production'); + $this->assertTrue(DevTools::enabled()); + + config()->set('inertia.devtools.enabled', false); + $this->app->instance('env', 'local'); + $this->assertFalse(DevTools::enabled()); + } + + public function testDefaultsToTheLocalEnvironmentWhenUnconfigured(): void + { + config()->set('inertia.devtools.enabled', null); + + $this->app->instance('env', 'local'); + $this->assertTrue(DevTools::enabled()); + + $this->app->instance('env', 'production'); + $this->assertFalse(DevTools::enabled()); + } + + public function testNoRecorderIsResolvedForAnExcludedPath(): void + { + config()->set('inertia.devtools.enabled', true); + config()->set('inertia.devtools.except', ['health']); + + // Without the request, recording is on; with it, an excluded path skips the whole + // lifecycle rather than collecting sources and props only to drop the entry later. + $this->assertNotNull(DevTools::recorder()); + $this->assertNotNull(DevTools::recorder(Request::create('/dashboard'))); + $this->assertNull(DevTools::recorder(Request::create('/health'))); + } + + public function testNoRecorderIsResolvedWhenDevtoolsIsDisabled(): void + { + config()->set('inertia.devtools.enabled', false); + + $this->assertNull(DevTools::recorder()); + $this->assertNull(DevTools::recorder(Request::create('/dashboard'))); + } + + public function testStringExceptPatternsAreHonored(): void + { + config()->set('inertia.devtools.enabled', true); + config()->set('inertia.devtools.except', ['admin/*', 42, null]); + + $this->assertFalse(DevTools::enabledForRequest(Request::create('/admin/users'))); + $this->assertTrue(DevTools::enabledForRequest(Request::create('/dashboard'))); + } + + public function testShippedListsMatchTheDefaultsUsedWhenOmitted(): void + { + $this->assertSame(DevTools::DEFAULT_EXCEPT, config('inertia.devtools.except')); + $this->assertSame(DevTools::DEFAULT_REDACT_KEYS, config('inertia.devtools.redact.keys')); + $this->assertSame(DevTools::DEFAULT_REDACT_HEADERS, config('inertia.devtools.redact.headers')); + } + + public function testOmittedExceptListKeepsTheDefaultExclusionsAndAnEmptyListRecordsEveryPath(): void + { + config()->set('inertia.devtools', ['enabled' => true]); + + $this->assertFalse(DevTools::enabledForRequest(Request::create('/_inertia/devtools/entries'))); + $this->assertTrue(DevTools::enabledForRequest(Request::create('/dashboard'))); + + config()->set('inertia.devtools.except', []); + + $this->assertTrue(DevTools::enabledForRequest(Request::create('/_inertia/devtools/entries'))); + } +} diff --git a/tests/Inertia/DevTools/EntriesRepositoryTest.php b/tests/Inertia/DevTools/EntriesRepositoryTest.php new file mode 100644 index 000000000..7a97a572e --- /dev/null +++ b/tests/Inertia/DevTools/EntriesRepositoryTest.php @@ -0,0 +1,319 @@ +storagePath = ParallelTesting::tempDir('InertiaDevToolsEntriesRepository'); + + (new Filesystem)->deleteDirectory($this->storagePath); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->storagePath); + + parent::tearDown(); + } + + /** + * Create a repository over the temporary storage directory. + */ + protected function makeRepository(): EntriesRepository + { + return new EntriesRepository( + path: $this->storagePath, + autoPruneHours: 24, + ); + } + + /** + * Build an entry payload with the given metadata overrides. + * + * @param array $metaOverrides + * @return array + */ + protected function envelope(array $metaOverrides = []): array + { + $id = $metaOverrides['id'] ?? (string) Str::ulid(); + + return [ + '__meta' => array_merge([ + 'id' => $id, + 'tabUuid' => 'tab-a', + 'batchId' => null, + 'timestamp' => '2026-05-12T10:00:00.000Z', + 'utime' => microtime(true), + 'method' => 'GET', + 'url' => 'http://app.test/users', + 'component' => 'Users/Index', + 'requestType' => 'navigate', + 'status' => 200, + 'serverTimingMs' => 1.5, + ], $metaOverrides), + 'http' => ['requestHeaders' => [], 'responseHeaders' => [], 'requestBody' => null, 'responseBody' => null], + 'props' => [], + 'propValues' => [], + 'route' => ['name' => null, 'uri' => '', 'action' => null], + ]; + } + + public function testSaveAndGetRoundTrip(): void + { + $repo = $this->makeRepository(); + $payload = $this->envelope(); + $id = $payload['__meta']['id']; + + $repo->save($id, $payload); + + $this->assertSame($payload, $repo->get($id)); + } + + public function testGetReturnsNullForMissingEntry(): void + { + $repo = $this->makeRepository(); + + $this->assertNull($repo->get('does-not-exist')); + } + + public function testSaveRejectsInvalidEntryIds(): void + { + $repo = $this->makeRepository(); + + $this->expectException(InvalidArgumentException::class); + + $repo->save('../secret', $this->envelope(['id' => '../secret'])); + } + + public function testGitignoreIsWrittenOnFirstSave(): void + { + $repo = $this->makeRepository(); + $payload = $this->envelope(); + $id = $payload['__meta']['id']; + + $repo->save($id, $payload); + + $gitignore = $this->storagePath . DIRECTORY_SEPARATOR . '.gitignore'; + + $this->assertFileExists($gitignore); + $this->assertSame("*\n", file_get_contents($gitignore)); + } + + public function testSaveUpdatesMetaIndexForHotPathLookups(): void + { + $repo = $this->makeRepository(); + $payload = $this->envelope(); + + $repo->save($payload['__meta']['id'], $payload); + + $metaPath = $this->storagePath . DIRECTORY_SEPARATOR . '_meta.json'; + + $this->assertFileExists($metaPath); + $index = json_decode((string) file_get_contents($metaPath), true); + + $this->assertIsArray($index); + $this->assertSame($payload['__meta']['id'], $index[$payload['__meta']['id']]['id']); + $this->assertSame($payload['__meta']['tabUuid'], $index[$payload['__meta']['id']]['tabUuid']); + } + + public function testAllRebuildsTheIndexWhenItIsCorrupt(): void + { + $repo = $this->makeRepository(); + $payload = $this->envelope(); + $repo->save($payload['__meta']['id'], $payload); + + file_put_contents($this->storagePath . DIRECTORY_SEPARATOR . '_meta.json', '{ not valid json'); + + $found = $repo->all(); + + $this->assertCount(1, $found); + $this->assertSame($payload['__meta']['id'], $found[0]['id']); + } + + public function testACorruptIndexDoesNotDropPriorEntriesOnTheNextSave(): void + { + $repo = $this->makeRepository(); + $first = $this->envelope(); + $repo->save($first['__meta']['id'], $first); + + file_put_contents($this->storagePath . DIRECTORY_SEPARATOR . '_meta.json', 'garbage'); + + $second = $this->envelope(); + $repo->save($second['__meta']['id'], $second); + + $ids = array_column($repo->all(), 'id'); + + $this->assertContains($first['__meta']['id'], $ids); + $this->assertContains($second['__meta']['id'], $ids); + } + + public function testAMissingIndexDoesNotDropPriorEntriesOnTheNextSave(): void + { + $repo = $this->makeRepository(); + $old = $this->envelope(['utime' => microtime(true) - (48 * 3600)]); + $repo->save($old['__meta']['id'], $old); + + unlink($this->storagePath . DIRECTORY_SEPARATOR . '_meta.json'); + + $fresh = $this->envelope(); + $repo->save($fresh['__meta']['id'], $fresh); + + $ids = array_column($repo->all(), 'id'); + + $this->assertContains($old['__meta']['id'], $ids); + $this->assertContains($fresh['__meta']['id'], $ids); + + $repo->prune(24); + + $this->assertNull($repo->get($old['__meta']['id'])); + $this->assertNotNull($repo->get($fresh['__meta']['id'])); + } + + public function testIndexRecoveryKeepsAnEntrySavedSinceTheIndexWasRead(): void + { + $first = $this->envelope(); + $this->makeRepository()->save($first['__meta']['id'], $first); + + unlink($this->storagePath . DIRECTORY_SEPARATOR . '_meta.json'); + + $second = $this->envelope(); + + // Another request saves an entry after this repository found the index missing, + // but before its recovery takes the index lock. + $recovering = new ConcurrentSaveEntriesRepository( + $this->storagePath, + function () use ($second): void { + $this->makeRepository()->save($second['__meta']['id'], $second); + }, + ); + + $recovered = array_column($recovering->all(), 'id'); + $listed = array_column($this->makeRepository()->all(), 'id'); + + foreach ([$recovered, $listed] as $ids) { + $this->assertContains($first['__meta']['id'], $ids); + $this->assertContains($second['__meta']['id'], $ids); + } + } + + public function testAllReturnsEveryEntrySortedDescendingById(): void + { + $repo = $this->makeRepository(); + + $ids = [(string) Str::ulid(), (string) Str::ulid(), (string) Str::ulid()]; + sort($ids); + + foreach ($ids as $id) { + $payload = $this->envelope(['id' => $id]); + $repo->save($id, $payload); + } + + $found = $repo->all(); + + $expected = array_reverse($ids); + + $this->assertSame($expected, array_column($found, 'id')); + } + + public function testEnforceTabLimitDropsOldestEntries(): void + { + $repo = $this->makeRepository(); + + $ids = []; + for ($i = 0; $i < 5; ++$i) { + $id = (string) Str::ulid(); + $ids[] = $id; + $repo->save($id, $this->envelope(['id' => $id, 'tabUuid' => 'tab-a'])); + } + + sort($ids); + + $repo->enforceTabLimit('tab-a', 2); + + $remaining = array_column($repo->all(), 'id'); + sort($remaining); + + $this->assertSame(array_slice($ids, -2), $remaining); + } + + public function testPruneDropsEntriesOlderThanCutoff(): void + { + $repo = $this->makeRepository(); + $old = $this->envelope(['utime' => microtime(true) - (48 * 3600)]); + $fresh = $this->envelope(); + + $repo->save($old['__meta']['id'], $old); + $repo->save($fresh['__meta']['id'], $fresh); + + $repo->prune(24); + + $this->assertNull($repo->get($old['__meta']['id'])); + $this->assertNotNull($repo->get($fresh['__meta']['id'])); + } + + public function testPruneIfDueSkipsUntilIntervalElapsed(): void + { + $repo = $this->makeRepository(); + $old = $this->envelope(['utime' => microtime(true) - (48 * 3600)]); + + $repo->save($old['__meta']['id'], $old); + $repo->pruneIfDue(); + + $this->assertNull($repo->get($old['__meta']['id'])); + + $fresh = $this->envelope(); + $repo->save($fresh['__meta']['id'], $fresh); + file_put_contents($this->storagePath . DIRECTORY_SEPARATOR . '_last_prune', (string) time(), LOCK_EX); + $repo->pruneIfDue(); + + $this->assertNotNull($repo->get($fresh['__meta']['id'])); + } +} + +class ConcurrentSaveEntriesRepository extends EntriesRepository +{ + /** + * Create a new repository instance that runs the callback before its next index lock. + */ + public function __construct(string $path, private ?Closure $beforeIndexLock) + { + parent::__construct(path: $path); + } + + /** + * Run the pending callback once, then apply the change under the index lock. + */ + protected function mutateIndex(callable $mutator): void + { + if ($this->beforeIndexLock !== null) { + $callback = $this->beforeIndexLock; + $this->beforeIndexLock = null; + + $callback(); + } + + parent::mutateIndex($mutator); + } +} diff --git a/tests/Inertia/DevTools/EntryStoreTest.php b/tests/Inertia/DevTools/EntryStoreTest.php new file mode 100644 index 000000000..aa9cf9c4b --- /dev/null +++ b/tests/Inertia/DevTools/EntryStoreTest.php @@ -0,0 +1,173 @@ +tabUuid = 'tab-a'; + $entry->component = 'Users/Index'; + + $repo = new RecorderSpyRepo; + + $recorder->record($entry); + $recorder->flush($repo); + + $this->assertCount(1, $repo->saved); + $this->assertSame($entry->id, array_key_first($repo->saved)); + $this->assertSame('Users/Index', $repo->saved[$entry->id]['__meta']['component']); + } + + public function testFlushPrunesAfterPersistingTheEntry(): void + { + $recorder = new EntryStore; + $repo = new RecorderSpyRepo; + + $recorder->record(new IncomingEntry); + $recorder->flush($repo); + + $this->assertSame(1, $repo->pruneCalls); + } + + public function testFlushWithoutAPendingEntryLeavesRepositoryUnchanged(): void + { + $recorder = new EntryStore; + $repo = new RecorderSpyRepo; + + $recorder->flush($repo); + + $this->assertSame([], $repo->saved); + $this->assertSame([], $repo->tabLimitCalls); + $this->assertSame(0, $repo->pruneCalls); + } + + public function testFlushEnforcesTheConfiguredTabLimitForTabbedEntries(): void + { + config()->set('inertia.devtools.storage.limit', 12); + + $recorder = new EntryStore; + $entry = new IncomingEntry; + $entry->tabUuid = 'tab-a'; + $repo = new RecorderSpyRepo; + + $recorder->record($entry); + $recorder->flush($repo); + + $this->assertSame([['tabUuid' => 'tab-a', 'limit' => 12]], $repo->tabLimitCalls); + } + + public function testFlushSkipsTabLimitEnforcementForEntriesWithoutTabId(): void + { + config()->set('inertia.devtools.storage.limit', 12); + + $recorder = new EntryStore; + $repo = new RecorderSpyRepo; + + $recorder->record(new IncomingEntry); + $recorder->flush($repo); + + $this->assertSame([], $repo->tabLimitCalls); + } + + public function testCircuitBreakerSuppressesAfterRepositoryError(): void + { + Log::shouldReceive('warning')->once(); + + $recorder = new EntryStore; + $failing = new FailingEntriesRepository; + + $first = new IncomingEntry; + $recorder->record($first); + $recorder->flush($failing); + + $second = new IncomingEntry; + $recorder->record($second); + $recorder->flush($failing); + + $this->assertSame(1, $failing->calls, 'Second flush should be suppressed by circuit breaker.'); + } +} + +/** + * In-memory spy over the real repository, so the EntryStore can be unit-tested without disk I/O. + */ +class RecorderSpyRepo extends EntriesRepository +{ + /** @var array> */ + public array $saved = []; + + /** @var array */ + public array $tabLimitCalls = []; + + public int $pruneCalls = 0; + + /** + * Create a new spy repository instance without a storage directory. + */ + public function __construct() + { + } + + /** + * Record the saved entry data. + * + * @param array $data + */ + public function save(string $id, array $data): void + { + $this->saved[$id] = $data; + } + + /** + * Record the tab limit enforcement. + */ + public function enforceTabLimit(string $tabUuid, int $limit): void + { + $this->tabLimitCalls[] = ['tabUuid' => $tabUuid, 'limit' => $limit]; + } + + /** + * Count the prune check. + */ + public function pruneIfDue(): void + { + ++$this->pruneCalls; + } +} + +// Local failing stub for the circuit-breaker test below. +class FailingEntriesRepository extends EntriesRepository +{ + public int $calls = 0; + + /** + * Create a new failing repository instance without a storage directory. + */ + public function __construct() + { + } + + /** + * Count the save attempt and fail it. + * + * @param array $data + */ + public function save(string $id, array $data): void + { + ++$this->calls; + + throw new RuntimeException('disk full'); + } +} diff --git a/tests/Inertia/DevTools/FlashDataTest.php b/tests/Inertia/DevTools/FlashDataTest.php new file mode 100644 index 000000000..126c647f1 --- /dev/null +++ b/tests/Inertia/DevTools/FlashDataTest.php @@ -0,0 +1,91 @@ +make('config'); + + // The entry endpoints run the `web` middleware group, which encrypts cookies. + $config->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.gate', 'viewInertiaDevtools'); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => true); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + public function testValidationErrorsSurviveTheEntryRequestRacingTheRedirect(): void + { + Route::middleware('web')->post('/users', fn (Request $request): array => $request->validate(['name' => 'required'])); + Route::middleware('web')->get('/users/create', fn (): array => session('errors')?->get('name') ?? []); + + $this->post('/users')->assertStatus(302); + + // The extension fetches the entry the moment the failed POST responds, so this lands + // between the redirect and the request the browser makes to follow it. + $this->getJson('/_inertia/devtools/entries')->assertOk(); + $this->getJson('/_inertia/devtools/entries/' . $this->savedEntryId())->assertOk(); + + $this->get('/users/create')->assertSee('The name field is required.'); + } + + public function testFlashedDataIsStillReadOnceByTheApp(): void + { + Route::middleware('web')->get('/app-page', fn (): string => (string) session('status')); + + $this->session(['status' => 'saved', '_flash' => ['old' => ['status'], 'new' => []]]); + + $this->getJson('/_inertia/devtools/entries')->assertOk(); + + $this->get('/app-page')->assertSee('saved'); + $this->get('/app-page')->assertDontSee('saved'); + } + + /** + * Save an entry and return its id. + */ + protected function savedEntryId(): string + { + $id = (string) Str::ulid(); + + $this->repo->save($id, ['__meta' => ['id' => $id]]); + + return $id; + } +} diff --git a/tests/Inertia/DevTools/HttpEndpointsTest.php b/tests/Inertia/DevTools/HttpEndpointsTest.php new file mode 100644 index 000000000..81d040161 --- /dev/null +++ b/tests/Inertia/DevTools/HttpEndpointsTest.php @@ -0,0 +1,149 @@ +make('config'); + + // The entry endpoints run the `web` middleware group, which encrypts cookies. + $config->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.gate', 'viewInertiaDevtools'); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + Gate::define('viewInertiaDevtools', fn (?Authenticatable $user = null): bool => true); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + /** + * Build an entry payload. + * + * @return array + */ + protected function envelope(string $tab = 'tab-a', ?string $id = null, ?string $batch = null): array + { + $id = $id ?? (string) Str::ulid(); + + return [ + '__meta' => [ + 'id' => $id, + 'tabUuid' => $tab, + 'batchId' => $batch, + 'timestamp' => '2026-05-12T10:00:00.000Z', + 'utime' => microtime(true), + 'method' => 'GET', + 'url' => 'http://app.test/', + 'component' => null, + 'requestType' => 'navigate', + 'status' => 200, + 'serverTimingMs' => 0.0, + ], + 'http' => ['requestHeaders' => [], 'responseHeaders' => [], 'requestBody' => null, 'responseBody' => null], + 'props' => [], + 'propValues' => [], + 'route' => ['name' => null, 'uri' => '', 'action' => null], + ]; + } + + public function testShowReturnsFullEntryOr404(): void + { + $entry = $this->envelope(); + $id = $entry['__meta']['id']; + $this->repo->save($id, $entry); + + $this->getJson("/_inertia/devtools/entries/{$id}") + ->assertOk() + ->assertJsonPath('__meta.id', $id); + + $this->getJson('/_inertia/devtools/entries/missing') + ->assertStatus(404); + } + + public function testShowResolvesUnderTheBasePathAnAppIsServedFrom(): void + { + $entry = $this->envelope(); + $id = $entry['__meta']['id']; + $this->repo->save($id, $entry); + + $this->call('GET', "/portal/_inertia/devtools/entries/{$id}", server: [ + 'SCRIPT_FILENAME' => '/var/www/app/public/index.php', + 'SCRIPT_NAME' => '/portal/index.php', + 'PHP_SELF' => '/portal/index.php', + ]) + ->assertOk() + ->assertJsonPath('__meta.id', $id); + } + + public function testShowRejectsNonEntryIdsBeforeLookup(): void + { + $this->getJson('/_inertia/devtools/entries/../secret') + ->assertStatus(404); + } + + public function testIndexFiltersByComponentTypeExcludeOffsetAndLimit(): void + { + $ids = []; + + foreach ([ + ['component' => 'Users/Index', 'requestType' => 'navigate'], + ['component' => 'Users/Index', 'requestType' => 'partial'], + ['component' => 'Users/Index', 'requestType' => 'poll'], + ['component' => 'Posts/Index', 'requestType' => 'navigate'], + ] as $i => $overrides) { + $id = (string) Str::ulid(); + $ids[] = $id; + $meta = array_merge(['id' => $id], $overrides); + $entry = $this->envelope(); + $entry['__meta'] = array_merge($entry['__meta'], $meta); + $this->repo->save($id, $entry); + } + + $this->getJson('/_inertia/devtools/entries?component=Users/Index') + ->assertJsonCount(3) + ->assertJsonMissing(['component' => 'Posts/Index']); + + $this->getJson('/_inertia/devtools/entries?type=navigate,partial') + ->assertJsonCount(3) + ->assertJsonMissing(['requestType' => 'poll']); + + $this->getJson('/_inertia/devtools/entries?exclude=poll') + ->assertJsonCount(3) + ->assertJsonMissing(['requestType' => 'poll']); + + $this->getJson('/_inertia/devtools/entries?limit=2')->assertJsonCount(2); + $this->getJson('/_inertia/devtools/entries?offset=1&limit=2')->assertJsonCount(2); + } +} diff --git a/tests/Inertia/DevTools/IncomingEntryBuilderMatrixTest.php b/tests/Inertia/DevTools/IncomingEntryBuilderMatrixTest.php new file mode 100644 index 000000000..2b6c96b48 --- /dev/null +++ b/tests/Inertia/DevTools/IncomingEntryBuilderMatrixTest.php @@ -0,0 +1,375 @@ +make('config'); + + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.except', []); + } + + /** + * Create an entry builder. + */ + protected function builder(): IncomingEntryBuilder + { + return new IncomingEntryBuilder(new SourceLocator); + } + + /** + * Create a request with the given headers and collector payload. + * + * @param array $headers + * @param null|array $payload + */ + protected function request(string $method = 'GET', string $uri = 'http://localhost/dashboard', array $headers = [], ?array $payload = null): Request + { + $request = Request::create($uri, $method); + + foreach ($headers as $name => $value) { + $request->headers->set($name, $value); + } + + if ($payload !== null) { + $request->attributes->set(RequestAttribute::PAYLOAD, $payload); + } + + return $request; + } + + /** + * Build the entry for the given request and response. + */ + protected function build(Request $request, Response $response, bool $isPrefetch = false): IncomingEntry + { + return $this->builder()->build($request, $response, 'entry-id', null, $isPrefetch); + } + + public function testRequestTypePrecedenceAcrossTheHeaderMatrix(): void + { + $response = new Response; + + $precognition = $this->request(headers: [Header::INERTIA => 'true', Header::PRECOGNITION => 'true', Header::PARTIAL_COMPONENT => 'Users/Form']); + $this->assertSame(RequestType::Precognition, $this->build($precognition, $response)->requestType); + + $http = $this->request(); + $this->assertSame(RequestType::Http, $this->build($http, $response)->requestType); + + $initial = $this->request(payload: ['component' => 'Users/Index']); + $this->assertSame(RequestType::Initial, $this->build($initial, $response)->requestType); + + $deferred = $this->request(headers: [Header::INERTIA => 'true', DevToolsHeader::DEVTOOLS_DEFERRED => '1', Header::PARTIAL_COMPONENT => 'Users/Index']); + $this->assertSame(RequestType::Deferred, $this->build($deferred, $response)->requestType); + + $poll = $this->request(headers: [Header::INERTIA => 'true', DevToolsHeader::DEVTOOLS_POLL => '1', Header::PARTIAL_COMPONENT => 'Users/Index']); + $this->assertSame(RequestType::Poll, $this->build($poll, $response)->requestType); + + $partial = $this->request(headers: [Header::INERTIA => 'true', Header::PARTIAL_COMPONENT => 'Users/Index']); + $this->assertSame(RequestType::Partial, $this->build($partial, $response)->requestType); + + $prefetch = $this->request(headers: [Header::INERTIA => 'true']); + $this->assertSame(RequestType::Prefetch, $this->build($prefetch, $response, isPrefetch: true)->requestType); + + $navigate = $this->request(headers: [Header::INERTIA => 'true']); + $this->assertSame(RequestType::Navigate, $this->build($navigate, $response)->requestType); + } + + public function testDeferredAndPollHeadersTakePrecedenceOverPartial(): void + { + $response = new Response; + + $deferredPartial = $this->request(headers: [ + Header::INERTIA => 'true', + Header::PARTIAL_COMPONENT => 'Users/Index', + DevToolsHeader::DEVTOOLS_DEFERRED => '1', + ]); + + $this->assertSame(RequestType::Deferred, $this->build($deferredPartial, $response)->requestType); + } + + public function testNonInertiaRequestWithoutAStringComponentPayloadStaysHttp(): void + { + $response = new Response; + + $emptyComponent = $this->request(payload: ['component' => '']); + $this->assertSame(RequestType::Http, $this->build($emptyComponent, $response)->requestType); + + $nonStringComponent = $this->request(payload: ['component' => ['nested']]); + $this->assertSame(RequestType::Http, $this->build($nonStringComponent, $response)->requestType); + } + + public function testInertiaLocationHeaderWinsOverStatusForRedirectLocation(): void + { + $request = $this->request(headers: [Header::INERTIA => 'true']); + $response = new Response('', 409, [Header::LOCATION => 'https://example.com/external']); + + $entry = $this->build($request, $response); + + $this->assertSame('https://example.com/external', $entry->redirectLocation); + $this->assertSame(409, $entry->status); + } + + public function test3xxResponsesUseTheStandardLocationHeader(): void + { + foreach ([301, 302, 307, 308] as $status) { + $response = new Response('', $status, ['Location' => '/elsewhere']); + + $this->assertSame('/elsewhere', $this->build($this->request(), $response)->redirectLocation); + } + } + + public function testRedirectLocationIsNullWhenNoLocationApplies(): void + { + $this->assertNull($this->build($this->request(), new Response('', 200))->redirectLocation); + $this->assertNull($this->build($this->request(), new Response('', 302))->redirectLocation); + $this->assertNull($this->build($this->request(), new Response('', 404, ['Location' => '/ignored']))->redirectLocation); + $this->assertNull($this->build($this->request(), new Response('', 500))->redirectLocation); + } + + public function testNonTextualStreamedAndOversizedResponseBodiesAreOmitted(): void + { + $binary = new Response('DATA', 200, ['Content-Type' => 'application/octet-stream']); + $this->assertSame(['status' => 'omitted', 'reason' => 'non-textual'], $this->build($this->request(), $binary)->http['responseBody']); + + $streamed = new StreamedResponse(fn (): int => print ('chunk'), 200, ['Content-Type' => 'text/plain']); + $this->assertSame(['status' => 'omitted', 'reason' => 'streamed'], $this->build($this->request(), $streamed)->http['responseBody']); + + $huge = new Response(str_repeat('a', 256_001), 200, ['Content-Type' => 'text/plain']); + $this->assertSame(['status' => 'omitted', 'reason' => 'too-large'], $this->build($this->request(), $huge)->http['responseBody']); + } + + public function testResponseBodyJustUnderTheLimitIsCaptured(): void + { + $body = str_repeat('a', 256_000); + $response = new Response($body, 200, ['Content-Type' => 'text/plain']); + + $this->assertSame(['status' => 'present', 'value' => $body], $this->build($this->request(), $response)->http['responseBody']); + } + + public function testTextualAndEmptyResponseBodiesAreCaptured(): void + { + $empty = new Response('', 200, ['Content-Type' => 'text/plain']); + $this->assertSame(['status' => 'empty'], $this->build($this->request(), $empty)->http['responseBody']); + + $json = new Response('{"token":"secret","name":"John"}', 200, ['Content-Type' => 'application/json']); + config()->set('inertia.devtools.redact.keys', ['token']); + $this->assertSame([ + 'status' => 'present', + 'value' => ['token' => '[REDACTED]', 'name' => 'John'], + ], $this->build($this->request(), $json)->http['responseBody']); + + $text = new Response('plain body', 200, ['Content-Type' => 'text/plain; charset=UTF-8']); + $this->assertSame(['status' => 'present', 'value' => 'plain body'], $this->build($this->request(), $text)->http['responseBody']); + } + + public function testMalformedJsonResponseFallsBackToTheRawString(): void + { + $response = new Response('{not valid json', 200, ['Content-Type' => 'application/json']); + + $this->assertSame(['status' => 'present', 'value' => '{not valid json'], $this->build($this->request(), $response)->http['responseBody']); + } + + public function testTextualResponseBodyWithInvalidUtf8IsOmittedAsBinary(): void + { + $response = new Response("valid\xB1\x31text", 200, ['Content-Type' => 'text/plain']); + + $this->assertSame(['status' => 'omitted', 'reason' => 'binary'], $this->build($this->request(), $response)->http['responseBody']); + } + + public function testInertiaResponseBodyVariantsAreCapturedFromThePayload(): void + { + $string = $this->request(payload: ['responseBody' => 'rendered html']); + $this->assertSame(['status' => 'present', 'value' => 'rendered html'], $this->build($string, new Response)->http['responseBody']); + + config()->set('inertia.devtools.redact.keys', ['token']); + $array = $this->request(payload: ['responseBody' => ['props' => ['token' => 'secret', 'name' => 'John']]]); + $this->assertSame([ + 'status' => 'present', + 'value' => ['props' => ['token' => '[REDACTED]', 'name' => 'John']], + ], $this->build($array, new Response)->http['responseBody']); + + $null = $this->request(payload: ['responseBody' => null]); + $this->assertSame(['status' => 'empty'], $this->build($null, new Response)->http['responseBody']); + + $scalar = $this->request(payload: ['responseBody' => 42]); + $this->assertSame(['status' => 'present', 'value' => 42], $this->build($scalar, new Response)->http['responseBody']); + } + + public function testNonInertiaWriteRequestBodiesAreRecordedAsMetadataOnly(): void + { + $request = Request::create('http://localhost/save', 'POST', ['name' => 'John']); + + $this->assertSame(['status' => 'omitted', 'reason' => 'non-inertia-request'], $this->build($request, new Response)->http['requestBody']); + } + + public function testInertiaJsonRequestBodyIsRedacted(): void + { + config()->set('inertia.devtools.redact.keys', ['password']); + + $request = Request::create('http://localhost/save', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_INERTIA' => 'true', + ], json_encode(['password' => 'secret', 'name' => 'John'])); + + $this->assertSame([ + 'status' => 'present', + 'value' => ['password' => '[REDACTED]', 'name' => 'John'], + ], $this->build($request, new Response)->http['requestBody']); + } + + public function testBinaryRequestBodyIsOmitted(): void + { + $request = Request::create('http://localhost/upload', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/octet-stream', + 'HTTP_X_INERTIA' => 'true', + ], "\xff\xfe\x00\x01binary"); + + $this->assertSame(['status' => 'omitted', 'reason' => 'binary'], $this->build($request, new Response)->http['requestBody']); + } + + public function testUploadedFilesAreSummarizedAndInvalidUploadsReportNullSize(): void + { + $valid = UploadedFile::fake()->create('resume.pdf', 12); + $request = Request::create('http://localhost/upload', 'POST', ['name' => 'John'], [], ['avatar' => $valid], [ + 'HTTP_X_INERTIA' => 'true', + ]); + + $body = $this->build($request, new Response)->http['requestBody']; + + $this->assertSame('present', $body['status']); + $this->assertSame('John', $body['value']['name']); + $this->assertSame('resume.pdf', $body['value']['avatar']['name']); + $this->assertSame(12 * 1024, $body['value']['avatar']['size']); + $this->assertArrayHasKey('mimeType', $body['value']['avatar']); + + $invalid = new UploadedFile(__FILE__, 'ghost.pdf', 'application/pdf', UPLOAD_ERR_NO_FILE, test: true); + $requestInvalid = Request::create('http://localhost/upload', 'POST', [], [], ['avatar' => $invalid], [ + 'HTTP_X_INERTIA' => 'true', + ]); + + $invalidBody = $this->build($requestInvalid, new Response)->http['requestBody']; + + $this->assertSame('ghost.pdf', $invalidBody['value']['avatar']['name']); + $this->assertNull($invalidBody['value']['avatar']['size']); + } + + public function testHeadersAreFlattenedToStringsAndSensitiveValuesRedacted(): void + { + config()->set('inertia.devtools.redact.headers', ['authorization']); + + $request = $this->request(headers: [ + 'Authorization' => 'Bearer secret', + 'Accept' => 'application/json', + ]); + $request->headers->set('X-Multi', ['a', 'b']); + + $response = new Response('', 200, ['X-Response-Multi' => ['x', 'y']]); + + $entry = $this->build($request, $response); + + $this->assertSame('[REDACTED]', $entry->http['requestHeaders']['authorization']); + $this->assertSame('application/json', $entry->http['requestHeaders']['accept']); + $this->assertSame('a, b', $entry->http['requestHeaders']['x-multi']); + $this->assertSame('x, y', $entry->http['responseHeaders']['x-response-multi']); + } + + public function testPropValuesAreSanitizedAndHugeValuesArePreserved(): void + { + $huge = array_map(fn (int $i): array => ['id' => $i, 'name' => 'User ' . $i], range(1, 5000)); + + $request = $this->request(payload: [ + 'component' => 'Users/Index', + 'propValues' => [ + 'users' => $huge, + 'blob' => "\xB1\x31", + 'nested' => ['ok' => 'value', 'bad' => "\xB1\x31"], + ], + ]); + + $entry = $this->build($request, new Response); + + $this->assertCount(5000, $entry->propValues['users']); + $this->assertSame('[UNSERIALIZABLE]', $entry->propValues['blob']); + $this->assertSame('value', $entry->propValues['nested']['ok']); + $this->assertSame('[UNSERIALIZABLE]', $entry->propValues['nested']['bad']); + } + + public function testRouteAndRenderSourceFallBackWhenAbsentFromPayload(): void + { + $entry = $this->build($this->request(), new Response); + + $this->assertSame(['name' => null, 'uri' => '', 'action' => null], $entry->route); + $this->assertNull($entry->renderSource); + } + + public function testRecorderNeverLetsABuilderFailure500TheResponse(): void + { + $this->app->instance(IncomingEntryBuilder::class, new ThrowingIncomingEntryBuilder(new SourceLocator)); + + $request = $this->request(); + $response = new Response('real response body', 200); + + app(RequestRecorder::class)->respondedWith($request, $response); + + $this->assertSame('real response body', $response->getContent()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($response->headers->get(DevToolsHeader::DEVTOOLS_ID)); + } + + public function testBuildDoesNotThrowOnAPathologicalResponse(): void + { + $request = Request::create('http://localhost/save', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_INERTIA' => 'true', + ], "\xff\xfe not json"); + + $response = new Response("body\xB1\x31", 500, ['Content-Type' => 'text/plain']); + + $entry = $this->build($request, $response); + + $this->assertSame(500, $entry->status); + } +} + +class ThrowingIncomingEntryBuilder extends IncomingEntryBuilder +{ + /** + * Fail to build the entry. + */ + public function build(Request $request, Response $response, string $id, ?string $batchId, bool $isPrefetch): IncomingEntry + { + throw new RuntimeException('builder exploded'); + } +} diff --git a/tests/Inertia/DevTools/IncomingEntryBuilderTest.php b/tests/Inertia/DevTools/IncomingEntryBuilderTest.php new file mode 100644 index 000000000..ea0d95333 --- /dev/null +++ b/tests/Inertia/DevTools/IncomingEntryBuilderTest.php @@ -0,0 +1,76 @@ +makeBuilder()->exposeCaptureBodyValue(['name' => 'John', 'items' => [1, 2, 3]]); + + $this->assertSame('present', $result['status']); + $this->assertSame(['name' => 'John', 'items' => [1, 2, 3]], $result['value']); + } + + public function testCaptureBodyValueOmitsUnserializablePayloads(): void + { + $result = $this->makeBuilder()->exposeCaptureBodyValue(['blob' => "\xB1\x31"]); + + $this->assertSame('omitted', $result['status']); + $this->assertSame('unserializable', $result['reason']); + $this->assertArrayNotHasKey('value', $result); + } + + public function testSanitizeForJsonMarksUnserializableLeavesAndKeepsSiblings(): void + { + $sanitized = $this->makeBuilder()->exposeSanitizeForJson([ + 'name' => 'John', + 'user' => ['email' => 'john@example.com', 'avatar' => "\xB1\x31"], + 'items' => [1, 2, 3], + ]); + + $this->assertSame([ + 'name' => 'John', + 'user' => ['email' => 'john@example.com', 'avatar' => '[UNSERIALIZABLE]'], + 'items' => [1, 2, 3], + ], $sanitized); + } +} + +class ExposedIncomingEntryBuilder extends IncomingEntryBuilder +{ + /** + * Capture the given body value when it can be encoded. + * + * @return array{status: string, value?: mixed, reason?: string} + */ + public function exposeCaptureBodyValue(mixed $value): array + { + return $this->captureBodyValue($value); + } + + /** + * Replace the leaf values that cannot be JSON encoded with a marker. + * + * @param array $data + * @return array + */ + public function exposeSanitizeForJson(array $data): array + { + return $this->sanitizeForJson($data); + } +} diff --git a/tests/Inertia/DevTools/InteractsWithDevToolsStorage.php b/tests/Inertia/DevTools/InteractsWithDevToolsStorage.php new file mode 100644 index 000000000..a713fa916 --- /dev/null +++ b/tests/Inertia/DevTools/InteractsWithDevToolsStorage.php @@ -0,0 +1,66 @@ +devtoolsStoragePath = ParallelTesting::tempDir('InertiaDevTools'); + + $this->clearDevToolsStorage(); + + $this->repo = new EntriesRepository(path: $this->devtoolsStoragePath, autoPruneHours: 24); + + $this->app->instance(EntriesRepository::class, $this->repo); + } + + /** + * Delete the temporary storage directory. + */ + protected function clearDevToolsStorage(): void + { + if (isset($this->devtoolsStoragePath)) { + (new Filesystem)->deleteDirectory($this->devtoolsStoragePath); + } + } + + /** + * Recorded entry metadata, newest first. + * + * @return array> + */ + protected function recordedEntries(): array + { + return $this->repo->all(); + } + + /** + * The full payload of the most recently recorded entry. + * + * @return null|array + */ + protected function latestRecordedEntry(): ?array + { + $metas = $this->recordedEntries(); + + return $metas === [] ? null : $this->repo->get($metas[0]['id']); + } +} diff --git a/tests/Inertia/DevTools/MiddlewareDevToolsDisabledTest.php b/tests/Inertia/DevTools/MiddlewareDevToolsDisabledTest.php new file mode 100644 index 000000000..9d0b53618 --- /dev/null +++ b/tests/Inertia/DevTools/MiddlewareDevToolsDisabledTest.php @@ -0,0 +1,74 @@ +make('config')->set('inertia.devtools.enabled', false); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + public function testNothingIsRecordedWhenDevtoolsIsDisabled(): void + { + Route::middleware(Middleware::class)->get('/devtools-off', fn (): Response => Inertia::render('Users/Index', ['name' => 'Alice'])); + + $response = $this->get('/devtools-off'); + + $response->assertOk(); + $this->assertNull($response->headers->get(DevToolsHeader::DEVTOOLS_ID)); + $this->assertStringNotContainsString('data-inertia-devtools-id', (string) $response->getContent()); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $this->assertSame([], $this->repo->all()); + } + + public function testTheEntryEndpointsAreNotRegisteredWhenDevtoolsIsDisabled(): void + { + $this->getJson('/_inertia/devtools/entries')->assertNotFound(); + } + + public function testNoRequestHandledListenerIsRegisteredWhenDevtoolsIsDisabled(): void + { + // The kernel only builds and dispatches the event when something listens for it. + $this->assertFalse($this->app->make('events')->hasListeners(RequestHandled::class)); + } +} diff --git a/tests/Inertia/DevTools/MiddlewareDevToolsTest.php b/tests/Inertia/DevTools/MiddlewareDevToolsTest.php new file mode 100644 index 000000000..11db2add1 --- /dev/null +++ b/tests/Inertia/DevTools/MiddlewareDevToolsTest.php @@ -0,0 +1,564 @@ +make('config'); + + $config->set('inertia.devtools.enabled', true); + $config->set('inertia.devtools.except', ['health', '_inertia/devtools*']); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->bindEntriesRepository(); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->clearDevToolsStorage(); + + parent::tearDown(); + } + + /** + * Persist the pending entry. + */ + protected function flushEntryStore(): void + { + $this->app->make(EntryStore::class)->flush($this->repo); + } + + /** + * Persist the pending entry and return the most recent one. + * + * @return null|array + */ + protected function lastSavedEntry(): ?array + { + $this->flushEntryStore(); + + return $this->latestRecordedEntry(); + } + + public function testDevtoolsIdHeaderIsSetOnEveryResponse(): void + { + Route::middleware(Middleware::class)->get('/devtools-target', fn (): string => 'ok'); + + $response = $this->get('/devtools-target'); + + $response->assertOk(); + $id = $response->headers->get(DevToolsHeader::DEVTOOLS_ID); + + $this->assertNotNull($id); + $this->assertNotSame('', $id); + } + + public function testDevtoolsParentOutHeaderIsSetOn3xxRedirects(): void + { + Route::middleware(Middleware::class)->get('/devtools-redirect', fn (): RedirectResponse => redirect('/elsewhere')); + + $response = $this->get('/devtools-redirect'); + + $response->assertRedirect('/elsewhere'); + $this->assertNotNull($response->headers->get(DevToolsHeader::DEVTOOLS_OUTGOING_PARENT)); + } + + public function testInitialInertiaHtmlResponseIncludesTheDevtoolsIdScriptTag(): void + { + Route::middleware(DevToolsRootViewMiddleware::class)->get('/devtools-html', fn (): Response => Inertia::render('Users/Index', ['name' => 'Alice'])); + + $response = $this->get('/devtools-html'); + + $response->assertOk(); + $content = $response->getContent(); + + $this->assertIsString($content); + $this->assertStringContainsString('data-inertia-devtools-id', $content); + $this->assertStringContainsString('', $content); + $this->assertMatchesRegularExpression('/" or "