diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index ecac07a5e..18755a1a1 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -7,4 +7,6 @@ ->ignoreErrorsOnExtension('ext-zlib', [ErrorType::UNUSED_DEPENDENCY]) ->ignoreErrorsOnPackageAndPath('spatie/laravel-ignition', __DIR__.'/src/Sensors/ExceptionSensor.php', [ErrorType::DEV_DEPENDENCY_IN_PROD]) ->ignoreErrorsOnPackageAndPath('spatie/laravel-ignition', __DIR__.'/src/Location.php', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackageAndPath('livewire/livewire', __DIR__.'/src/NightwatchServiceProvider.php', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackageAndPath('livewire/livewire', __DIR__.'/src/Hooks/LivewireListener.php', [ErrorType::DEV_DEPENDENCY_IN_PROD]) ->ignoreUnknownClasses(['Laravel\Octane\Events\RequestReceived']); diff --git a/composer.json b/composer.json index c949cf155..6311fe8fe 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "guzzlehttp/psr7": "^2.0", "laravel/horizon": "^5.4", "laravel/pint": "1.21.0", + "livewire/livewire": "^2.0|^3.0", "mockery/mockery": "^1.0", "mongodb/laravel-mongodb": "^4.0|^5.0", "orchestra/testbench": "^8.0|^9.0|^10.0", diff --git a/src/Concerns/CapturesState.php b/src/Concerns/CapturesState.php index 9104687b3..a6ff5861b 100644 --- a/src/Concerns/CapturesState.php +++ b/src/Concerns/CapturesState.php @@ -407,6 +407,19 @@ public function captureRequestPreview(Request $request): void ); } + /** + * @internal + */ + public function captureRequestRouteAction(string $routeAction): void + { + /** @var Core $this */ + if ($this->executionState->routeAction === null) { + $this->executionState->routeAction = $routeAction; + } else { + $this->executionState->routeAction .= ', '.$routeAction; + } + } + /** * @internal */ diff --git a/src/Hooks/LivewireListener.php b/src/Hooks/LivewireListener.php new file mode 100644 index 000000000..3fcf443af --- /dev/null +++ b/src/Hooks/LivewireListener.php @@ -0,0 +1,89 @@ + $nightwatch + */ + public function __construct( + private Core $nightwatch + ) { + // + } + + /* Livewire 2 Events + * + * Initial request: + * - component.boot + * - component.hydrate + * - component.hydrate.initial + * - component.mount + * - component.booted + * - component.rendering + * - component.rendered + * - view:render + * - component.dehydrate + * - component.dehydrate.initial + * - property.dehydrate + * - mounted + * + * Update request: + * - component.boot + * - property.hydrate + * - component.hydrate + * - component.hydrate.subsequent + * - component.booted + * - component.updating + * - component.updated + * - action.returned + * - component.rendering + * - component.rendered + * - view:render + * - component.dehydrate + * - component.dehydrate.subsequent + * - property.dehydrate + */ + + public function componentHydrateSubsequent(Component $component): void + { + $this->nightwatch->captureRequestRouteAction($component::class); + } + + /* Livewire 3 Events + * + * Initial request: + * - pre-mount + * - mount + * - render + * - view:compile + * - dehydrate + * - checksum:generate + * - destroy + * + * Update request: + * - request + * - checksum.verify + * - checksum.generate + * - snapshot-verified + * - hydrate + * - update + * - call + * - render + * - view:compile + * - dehydrate + * - checksum.generate + * - destroy + * - response + */ + + public function hydrate(Component $component): void + { + $this->nightwatch->captureRequestRouteAction($component::class); + } +} diff --git a/src/NightwatchServiceProvider.php b/src/NightwatchServiceProvider.php index 9cf432af9..73168b87e 100644 --- a/src/NightwatchServiceProvider.php +++ b/src/NightwatchServiceProvider.php @@ -50,6 +50,7 @@ use Laravel\Nightwatch\Hooks\GlobalMiddleware; use Laravel\Nightwatch\Hooks\HttpClientFactoryResolvedHandler; use Laravel\Nightwatch\Hooks\HttpKernelResolvedHandler; +use Laravel\Nightwatch\Hooks\LivewireListener; use Laravel\Nightwatch\Hooks\LogoutListener; use Laravel\Nightwatch\Hooks\MailListener; use Laravel\Nightwatch\Hooks\NotificationListener; @@ -67,8 +68,11 @@ use Laravel\Nightwatch\State\CommandState; use Laravel\Nightwatch\State\RequestState; use Laravel\Octane\Events\RequestReceived; +use Livewire\Livewire; +use Livewire\LivewireManager; use Throwable; +use function class_exists; use function defined; use function microtime; @@ -409,6 +413,8 @@ private function registerRequestHooks(Dispatcher $events, Core $core): void * @see \Laravel\Nightwatch\Core::digest() */ $this->callAfterResolving(HttpKernelContract::class, (new HttpKernelResolvedHandler($core))(...)); + + $this->registerLivewireHooks($core); } /** @@ -452,6 +458,30 @@ private function registerConsoleHooks(Dispatcher $events, Core $core): void $events->listen(CommandStarting::class, (new CommandStartingListener($events, $core, $kernel))(...)); } + /** + * @param Core $core + */ + private function registerLivewireHooks(Core $core): void + { + if (! class_exists(Livewire::class)) { + return; + } + + $this->app->booted(static function ($app) use ($core) { + if (! $app->bound(LivewireManager::class)) { + return; + } + + $listener = new LivewireListener($core); + + // Livewire 2 + Livewire::listen('component.hydrate.subsequent', $listener->componentHydrateSubsequent(...)); + + // Livewire 3 + Livewire::listen('hydrate', $listener->hydrate(...)); + }); + } + private function executionState(): RequestState|CommandState { $trace = (string) Str::uuid(); diff --git a/src/Sensors/RequestSensor.php b/src/Sensors/RequestSensor.php index 47e8a51ed..a7aa13353 100644 --- a/src/Sensors/RequestSensor.php +++ b/src/Sensors/RequestSensor.php @@ -69,7 +69,7 @@ public function __invoke(Request $request, Response $response): array routeMethods: $routeMethods, routeDomain: $routeDomain, routePath: $routePath, - routeAction: $route?->getActionName() ?? '', + routeAction: $this->requestState->routeAction ?? $route?->getActionName() ?? '', ip: $request->ip() ?? '', duration: array_sum($this->requestState->stageDurations), statusCode: $response->getStatusCode(), diff --git a/src/State/RequestState.php b/src/State/RequestState.php index cb31ec776..c15c078ce 100644 --- a/src/State/RequestState.php +++ b/src/State/RequestState.php @@ -40,6 +40,7 @@ public function __construct( public string $server, public float $currentExecutionStageStartedAtMicrotime, public UserProvider $user, + public ?string $routeAction = null, public ExecutionStage $stage = ExecutionStage::Bootstrap, public array $stageDurations = [ ExecutionStage::Bootstrap->value => 0, @@ -104,6 +105,7 @@ public function peakMemory(): int public function flush(): void { + $this->routeAction = null; $this->exceptions = 0; $this->logs = 0; $this->queries = 0; diff --git a/src/UserProvider.php b/src/UserProvider.php index 466fa05cb..7208cfbcd 100644 --- a/src/UserProvider.php +++ b/src/UserProvider.php @@ -42,29 +42,63 @@ public function __construct( */ public function id(): LazyValue|string { - try { + if (! $this->auth->hasResolvedGuards()) { + return $this->lazyUserId(); + } + + if ($this->auth->hasUser()) { + return $this->currentUserId(); + } + + if ($this->rememberedUser) { + return $this->rememberedUserId(); + } + + return ''; + } + + /** + * @return LazyValue + */ + private function lazyUserId(): LazyValue + { + return new LazyValue(function () { + if (! $this->auth->hasResolvedGuards()) { + return ''; + } + if ($this->auth->hasUser()) { - return Str::tinyText((string) $this->auth->id()); + return $this->currentUserId(); + } + + if ($this->rememberedUser) { + return $this->rememberedUserId(); } + + return ''; + }); + } + + private function currentUserId(): string + { + try { + return Str::tinyText((string) $this->auth->id()); } catch (Throwable $e) { $this->reportResolvingUserIdException($e); return ''; } + } - return new LazyValue(function () { - try { - if ($this->auth->hasUser()) { - return Str::tinyText((string) $this->auth->id()); - } else { - return Str::tinyText((string) $this->rememberedUser?->getAuthIdentifier()); // @phpstan-ignore cast.string - } - } catch (Throwable $e) { - $this->reportResolvingUserIdException($e); + private function rememberedUserId(): string + { + try { + return Str::tinyText((string) $this->rememberedUser?->getAuthIdentifier()); // @phpstan-ignore cast.string + } catch (Throwable $e) { + $this->reportResolvingUserIdException($e); - return ''; - } - }); + return ''; + } } /** @@ -72,7 +106,9 @@ public function id(): LazyValue|string */ public function details(): ?array { - $user = $this->auth->user() ?? $this->rememberedUser; + $user = $this->auth->hasResolvedGuards() + ? $this->auth->user() ?? $this->rememberedUser + : $this->rememberedUser; if ($user === null) { return null; diff --git a/testbench.yaml b/testbench.yaml index 937c5b5b4..46279bf3b 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -1,6 +1,7 @@ providers: - Laravel\Nightwatch\NightwatchServiceProvider - Laravel\Horizon\HorizonServiceProvider + - Livewire\LivewireServiceProvider workbench: start: '/' diff --git a/tests/FakeIngest.php b/tests/FakeIngest.php index a55cd6de0..7349c41e7 100644 --- a/tests/FakeIngest.php +++ b/tests/FakeIngest.php @@ -138,7 +138,7 @@ public function decodedWrites(): Collection public function forgetWrites(): void { - $this->streams->forget($this->streams->keys()); + $this->streams->pop($this->streams->count()); } public function __get(string $name): mixed diff --git a/tests/Feature/Sensors/RequestSensorTest.php b/tests/Feature/Sensors/RequestSensorTest.php index 7be94cd26..06d70cd06 100644 --- a/tests/Feature/Sensors/RequestSensorTest.php +++ b/tests/Feature/Sensors/RequestSensorTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Sensors; use App\Http\UserController; +use App\Livewire\Counter; use Carbon\CarbonImmutable; +use Composer\InstalledVersions; use Exception; use Illuminate\Auth\GenericUser; use Illuminate\Contracts\Http\Kernel; @@ -11,24 +13,31 @@ use Illuminate\Foundation\Http\Events\RequestHandled; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Laravel\Nightwatch\ExecutionStage; use Laravel\Nightwatch\SensorManager; +use Livewire\Livewire; use Tests\TestCase; use function fseek; use function fwrite; use function hash; +use function html_entity_decode; +use function json_decode; use function now; use function ob_end_clean; use function ob_start; +use function preg_match; +use function preg_match_all; use function report; use function response; use function stream_get_meta_data; use function tap; use function tmpfile; +use function version_compare; class RequestSensorTest extends TestCase { @@ -763,4 +772,194 @@ public function test_it_supports_custom_request_methods(): void $ingest->assertLatestWrite('request:0.method', 'BLAH'); $ingest->assertLatestWrite('request:0.route_methods', ['BLAH']); } + + public function test_livewire_2(): void + { + $this->markTestSkippedWhen(version_compare(InstalledVersions::getVersion('livewire/livewire'), '3.0.0', '>='), 'Requires Livewire 2'); + + $ingest = $this->fakeIngest(); + Config::set('livewire.class_namespace', 'App\\Livewire'); // This is the default for Livewire 3, but we set it here to ensure compatibility with Livewire 2. + Route::get('/counter', Counter::class); + + $response = $this + ->get('/counter') + ->assertOk(); + + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.url', 'http://localhost/counter'); + $ingest->assertLatestWrite('request:0.route_path', '/counter'); + $ingest->assertLatestWrite('request:0.route_action', 'App\Livewire\Counter'); + + $ingest->forgetWrites(); + $this->core->prepareForNextRequest(); + + preg_match('/wire:initial-data="([^"]+)"/', $response->getContent(), $matches); + $snapshot = json_decode(html_entity_decode($matches[1]), true); + + $response = $this + ->withHeader('X-Livewire', true) + ->post('/livewire/message/counter', [ + 'fingerprint' => $snapshot['fingerprint'], + 'serverMemo' => $snapshot['serverMemo'], + 'updates' => [ + [ + 'type' => 'syncInput', + 'payload' => [ + 'name' => 'count', + 'value' => 2, + ], + ], + [ + 'type' => 'callMethod', + 'payload' => [ + 'id' => 'foo', + 'method' => 'increment', + 'params' => [], + ], + ], + [ + 'type' => 'callMethod', + 'payload' => [ + 'id' => 'foo', + 'method' => 'increment', + 'params' => [], + ], + ], + [ + 'type' => 'callMethod', + 'payload' => [ + 'id' => 'foo', + 'method' => 'decrement', + 'params' => [], + ], + ], + ], + ]) + ->assertOk(); + + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.url', 'http://localhost/livewire/message/counter'); + $ingest->assertLatestWrite('request:0.route_path', '/livewire/message/{name}'); + $ingest->assertLatestWrite('request:0.route_action', 'App\Livewire\Counter'); + } + + public function test_livewire_3(): void + { + $this->markTestSkippedWhen(version_compare(InstalledVersions::getVersion('livewire/livewire'), '3.0.0', '<'), 'Requires Livewire 3'); + + $ingest = $this->fakeIngest(); + Route::get('/counter', Counter::class); + + $response = $this + ->get('/counter') + ->assertOk(); + + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.url', 'http://localhost/counter'); + $ingest->assertLatestWrite('request:0.route_path', '/counter'); + $ingest->assertLatestWrite('request:0.route_action', 'App\Livewire\Counter'); + + $ingest->forgetWrites(); + $this->core->prepareForNextRequest(); + + preg_match('/wire:snapshot="([^"]+)"/', $response->getContent(), $matches); + $snapshot = html_entity_decode($matches[1]); + + $response = $this + ->withHeader('X-Livewire', true) + ->post('/livewire/update', [ + 'components' => [ + [ + 'snapshot' => $snapshot, + 'updates' => [ + 'count' => 2, + ], + 'calls' => [ + [ + 'method' => 'increment', + 'params' => [], + ], + [ + 'method' => 'increment', + 'params' => [], + ], + [ + 'method' => 'decrement', + 'params' => [], + ], + ], + ], + ], + ]) + ->assertOk(); + + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.url', 'http://localhost/livewire/update'); + $ingest->assertLatestWrite('request:0.route_path', '/livewire/update'); + $ingest->assertLatestWrite('request:0.route_action', 'App\Livewire\Counter'); + } + + public function test_livewire_3_with_multiple_components(): void + { + $this->markTestSkippedWhen(version_compare(InstalledVersions::getVersion('livewire/livewire'), '3.0.0', '<'), 'Requires Livewire 3'); + + $ingest = $this->fakeIngest(); + Route::view('/dashboard', 'dashboard'); + + $response = $this + ->get('/dashboard') + ->assertOk(); + + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.url', 'http://localhost/dashboard'); + $ingest->assertLatestWrite('request:0.route_path', '/dashboard'); + $ingest->assertLatestWrite('request:0.route_action', '\Illuminate\Routing\ViewController'); + + $ingest->forgetWrites(); + $this->core->prepareForNextRequest(); + + preg_match_all('/wire:snapshot="([^"]+)"/', $response->getContent(), $matches); + $snapshot1 = html_entity_decode($matches[1][0]); + $snapshot2 = html_entity_decode($matches[1][1]); + + $response = $this + ->withHeader('X-Livewire', true) + ->post('/livewire/update', [ + 'components' => [ + [ + 'snapshot' => $snapshot1, + 'updates' => [ + 'count' => 2, + ], + 'calls' => [ + [ + 'method' => 'increment', + 'params' => [], + ], + [ + 'method' => 'increment', + 'params' => [], + ], + [ + 'method' => 'decrement', + 'params' => [], + ], + ], + ], + [ + 'snapshot' => $snapshot2, + 'updates' => [ + 'count' => 2, + ], + 'calls' => [], + ], + ], + ]) + ->assertOk(); + + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.url', 'http://localhost/livewire/update'); + $ingest->assertLatestWrite('request:0.route_path', '/livewire/update'); + $ingest->assertLatestWrite('request:0.route_action', 'App\Livewire\Counter, App\Livewire\AnotherCounter'); + } } diff --git a/tests/Feature/Sensors/UserSensorTest.php b/tests/Feature/Sensors/UserSensorTest.php index 3f72ba888..06378a3f2 100644 --- a/tests/Feature/Sensors/UserSensorTest.php +++ b/tests/Feature/Sensors/UserSensorTest.php @@ -248,4 +248,15 @@ public function test_it_gracefully_handles_exceptions_while_resolving_user_ids() $ingest->assertLatestWrite('query:1.user', ''); $ingest->assertLatestWrite('request:0.user', ''); } + + public function test_it_does_not_actively_resolve_guards(): void + { + $this->fakeIngest(); + Route::get('/test', fn () => 'ok'); + + $response = $this->get('/test'); + + $response->assertOk(); + $this->assertFalse(Auth::hasResolvedGuards()); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index b6a680bae..466cbe790 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -27,6 +27,7 @@ use function array_combine; use function array_intersect_key; use function collect; +use function dd; use function env; use function fopen; use function method_exists; @@ -47,11 +48,12 @@ protected function setUp(): void { $_ENV['APP_BASE_PATH'] = realpath(__DIR__.'/../workbench/').'/'; + Nightwatch::handleUnrecoverableExceptionsUsing(fn ($e) => dd($e)); + parent::setUp(); Http::preventStrayRequests(); - Nightwatch::handleUnrecoverableExceptionsUsing(fn ($e) => throw $e); Compatibility::$context = []; $this->core = $this->app->make(Core::class); diff --git a/tests/Unit/Hooks/GuzzleMiddlewareTest.php b/tests/Unit/Hooks/GuzzleMiddlewareTest.php index 94662f297..ad82f5df6 100644 --- a/tests/Unit/Hooks/GuzzleMiddlewareTest.php +++ b/tests/Unit/Hooks/GuzzleMiddlewareTest.php @@ -14,8 +14,10 @@ class GuzzleMiddlewareTest extends TestCase public function test_it_gracefully_handles_exceptions_in_the_before_middleware(): void { $exceptions = []; - $this->core->sensor->exceptionSensor = function ($e) use (&$exceptions): void { + $this->core->sensor->exceptionSensor = function ($e) use (&$exceptions): array { $exceptions[] = $e; + + return []; }; $thrownInMicrotimeResolver = false; $this->core->clock->microtimeResolver = function () use (&$thrownInMicrotimeResolver): float { diff --git a/tests/Unit/SamplingTest.php b/tests/Unit/SamplingTest.php index 7726b6281..a7dd5babd 100644 --- a/tests/Unit/SamplingTest.php +++ b/tests/Unit/SamplingTest.php @@ -224,7 +224,7 @@ public function test_it_samples_on_exception(): void { $ingest = $this->fakeIngest(); $this->core->config['sampling']['requests'] = 0; - $this->core->sensor->exceptionSensor = fn () => null; + $this->core->sensor->exceptionSensor = fn () => []; $exception = new RuntimeException('Whoops!'); Route::get('/users', fn () => throw $exception); diff --git a/tests/Unit/UserProviderTest.php b/tests/Unit/UserProviderTest.php index 1b6e46aa6..e5607e5dd 100644 --- a/tests/Unit/UserProviderTest.php +++ b/tests/Unit/UserProviderTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use App\Models\User; use Illuminate\Auth\GenericUser; use Illuminate\Support\Facades\Auth; use Laravel\Nightwatch\UserProvider; @@ -15,6 +16,13 @@ class UserProviderTest extends TestCase { + protected function setUp(): void + { + $this->forceRequestExecutionState(); + + parent::setUp(); + } + public function test_it_limits_the_length_of_the_user_identifier(): void { Auth::login(new GenericUser([ @@ -41,12 +49,12 @@ public function test_it_can_lazily_retrieve_the_user(): void public function test_it_can_remember_an_authenticated_user_and_limits_the_length_of_their_identifier(): void { - $provider = new UserProvider($this->app['auth'], fn () => [], fn () => fn () => null); - $provider->remember($user = new GenericUser([ + Auth::login((new User([ 'id' => str_repeat('x', 1000), - ])); + ]))->setKeyType('string')); + Auth::logout(); - $this->assertSame(str_repeat('x', 255), $provider->id()->jsonSerialize()); + $this->assertSame(str_repeat('x', 255), $this->core->executionState->user->id()); } public function test_it_only_reports_exceptions_occurring_while_resolving_user_ids_once_before_user_is_available(): void diff --git a/version.txt b/version.txt index 587c5f0c7..18b311420 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.10.3 +1.10.4 diff --git a/workbench/app/Livewire/AnotherCounter.php b/workbench/app/Livewire/AnotherCounter.php new file mode 100644 index 000000000..e06e5beb1 --- /dev/null +++ b/workbench/app/Livewire/AnotherCounter.php @@ -0,0 +1,29 @@ +count++; + } + + public function decrement() + { + $this->count--; + } + + public function render() + { + return <<<'HTML' +
{{ $count }}
+ HTML; + } +} diff --git a/workbench/app/Livewire/Counter.php b/workbench/app/Livewire/Counter.php new file mode 100644 index 000000000..1d58d2d43 --- /dev/null +++ b/workbench/app/Livewire/Counter.php @@ -0,0 +1,29 @@ +count++; + } + + public function decrement() + { + $this->count--; + } + + public function render() + { + return <<<'HTML' +
{{ $count }}
+ HTML; + } +} diff --git a/workbench/composer.json b/workbench/composer.json new file mode 100644 index 000000000..f36c8cd0c --- /dev/null +++ b/workbench/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/workbench/resources/views/dashboard.blade.php b/workbench/resources/views/dashboard.blade.php new file mode 100644 index 000000000..71a214b9f --- /dev/null +++ b/workbench/resources/views/dashboard.blade.php @@ -0,0 +1,2 @@ + + diff --git a/workbench/resources/views/layouts/app.blade.php b/workbench/resources/views/layouts/app.blade.php new file mode 100644 index 000000000..3338f620e --- /dev/null +++ b/workbench/resources/views/layouts/app.blade.php @@ -0,0 +1 @@ +{{ $slot }} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy