This commit is contained in:
TiclemFR
2023-12-29 17:47:40 +01:00
parent 936d5f15d9
commit 076ec32c3b
565 changed files with 339154 additions and 110 deletions

View File

@@ -0,0 +1,68 @@
<?php
namespace Inertia\Commands;
use Illuminate\Console\GeneratorCommand;
use Symfony\Component\Console\Input\InputOption;
class CreateMiddleware extends GeneratorCommand
{
/**
* The console command name.
*
* @var string
*/
protected $name = 'inertia:middleware';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new Inertia middleware';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Middleware';
/**
* Get the stub file for the generator.
*/
protected function getStub(): string
{
return __DIR__.'/../../stubs/middleware.stub';
}
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
*/
protected function getDefaultNamespace($rootNamespace): string
{
return $rootNamespace.'\Http\Middleware';
}
/**
* Get the console command arguments.
*/
protected function getArguments(): array
{
return [
['name', InputOption::VALUE_REQUIRED, 'Name of the Middleware that should be created', 'HandleInertiaRequests'],
];
}
/**
* Get the console command options.
*/
protected function getOptions(): array
{
return [
['force', null, InputOption::VALUE_NONE, 'Create the class even if the Middleware already exists'],
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Inertia\Commands;
use Inertia\Ssr\SsrException;
use Illuminate\Console\Command;
use Inertia\Ssr\BundleDetector;
use Symfony\Component\Process\Process;
class StartSsr extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'inertia:start-ssr {--runtime=node : The runtime to use (`node` or `bun`)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Start the Inertia SSR server';
/**
* Start the SSR server via a Node process.
*/
public function handle(): int
{
if (! config('inertia.ssr.enabled', true)) {
$this->error('Inertia SSR is not enabled. Enable it via the `inertia.ssr.enabled` config option.');
return self::FAILURE;
}
$bundle = (new BundleDetector())->detect();
$configuredBundle = config('inertia.ssr.bundle');
if ($bundle === null) {
$this->error(
$configuredBundle
? 'Inertia SSR bundle not found at the configured path: "'.$configuredBundle.'"'
: 'Inertia SSR bundle not found. Set the correct Inertia SSR bundle path in your `inertia.ssr.bundle` config.'
);
return self::FAILURE;
} elseif ($configuredBundle && $bundle !== $configuredBundle) {
$this->warn('Inertia SSR bundle not found at the configured path: "'.$configuredBundle.'"');
$this->warn('Using a default bundle instead: "'.$bundle.'"');
}
$runtime = $this->option('runtime');
if (! in_array($runtime, ['node', 'bun'])) {
$this->error('Unsupported runtime: "'.$runtime.'". Supported runtimes are `node` and `bun`.');
return self::INVALID;
}
$this->callSilently('inertia:stop-ssr');
$process = new Process([$runtime, $bundle]);
$process->setTimeout(null);
$process->start();
if (extension_loaded('pcntl')) {
$stop = function () use ($process) {
$process->stop();
};
pcntl_async_signals(true);
pcntl_signal(SIGINT, $stop);
pcntl_signal(SIGQUIT, $stop);
pcntl_signal(SIGTERM, $stop);
}
foreach ($process as $type => $data) {
if ($process::OUT === $type) {
$this->info(trim($data));
} else {
$this->error(trim($data));
report(new SsrException($data));
}
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Inertia\Commands;
use Illuminate\Console\Command;
class StopSsr extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $name = 'inertia:stop-ssr';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Stop the Inertia SSR server';
/**
* Stop the SSR server.
*/
public function handle(): int
{
$url = str_replace('/render', '', config('inertia.ssr.url', 'http://127.0.0.1:13714')).'/shutdown';
$ch = curl_init($url);
curl_exec($ch);
if (curl_error($ch) !== 'Empty reply from server') {
$this->error('Unable to connect to Inertia SSR server.');
return self::FAILURE;
}
$this->info('Inertia SSR server stopped.');
curl_close($ch);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Inertia;
use Illuminate\Http\Request;
class Controller
{
public function __invoke(Request $request): Response
{
return Inertia::render(
$request->route()->defaults['component'],
$request->route()->defaults['props']
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Inertia;
class Directive
{
/**
* Compiles the "@inertia" directive.
*
* @param string $expression
*/
public static function compile($expression = ''): string
{
$id = trim(trim($expression), "\'\"") ?: 'app';
$template = '<?php
if (!isset($__inertiaSsrDispatched)) {
$__inertiaSsrDispatched = true;
$__inertiaSsrResponse = app(\Inertia\Ssr\Gateway::class)->dispatch($page);
}
if ($__inertiaSsrResponse) {
echo $__inertiaSsrResponse->body;
} else {
?><div id="'.$id.'" data-page="{{ json_encode($page) }}"></div><?php
}
?>';
return implode(' ', array_map('trim', explode("\n", $template)));
}
/**
* Compiles the "@inertiaHead" directive.
*
* @param string $expression
*/
public static function compileHead($expression = ''): string
{
$template = '<?php
if (!isset($__inertiaSsrDispatched)) {
$__inertiaSsrDispatched = true;
$__inertiaSsrResponse = app(\Inertia\Ssr\Gateway::class)->dispatch($page);
}
if ($__inertiaSsrResponse) {
echo $__inertiaSsrResponse->head;
}
?>';
return implode(' ', array_map('trim', explode("\n", $template)));
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Inertia;
use Illuminate\Support\Facades\Facade;
/**
* @method static void setRootView(string $name)
* @method static void share(string|array|\Illuminate\Contracts\Support\Arrayable $key, mixed $value = null)
* @method static mixed getShared(string|null $key = null, mixed $default = null)
* @method static void flushShared()
* @method static void version(\Closure|string|null $version)
* @method static string getVersion()
* @method static \Inertia\LazyProp lazy(callable $callback)
* @method static \Inertia\Response render(string $component, array|\Illuminate\Contracts\Support\Arrayable $props = [])
* @method static \Symfony\Component\HttpFoundation\Response location(string|\Symfony\Component\HttpFoundation\RedirectResponse $url)
* @method static void macro(string $name, object|callable $macro)
* @method static void mixin(object $mixin, bool $replace = true)
* @method static bool hasMacro(string $name)
* @method static void flushMacros()
*
* @see \Inertia\ResponseFactory
*/
class Inertia extends Facade
{
protected static function getFacadeAccessor(): string
{
return ResponseFactory::class;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Inertia;
use Illuminate\Support\Facades\App;
class LazyProp
{
protected $callback;
public function __construct(callable $callback)
{
$this->callback = $callback;
}
public function __invoke()
{
return App::call($this->callback);
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Inertia;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Symfony\Component\HttpFoundation\Response;
class Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*
* @return string|null
*/
public function version(Request $request)
{
if (config('app.asset_url')) {
return md5(config('app.asset_url'));
}
if (file_exists($manifest = public_path('mix-manifest.json'))) {
return md5_file($manifest);
}
if (file_exists($manifest = public_path('build/manifest.json'))) {
return md5_file($manifest);
}
return null;
}
/**
* Defines the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array
*/
public function share(Request $request)
{
return [
'errors' => function () use ($request) {
return $this->resolveValidationErrors($request);
},
];
}
/**
* Sets the root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @return string
*/
public function rootView(Request $request)
{
return $this->rootView;
}
/**
* Handle the incoming request.
*
* @return Response
*/
public function handle(Request $request, Closure $next)
{
Inertia::version(function () use ($request) {
return $this->version($request);
});
Inertia::share($this->share($request));
Inertia::setRootView($this->rootView($request));
$response = $next($request);
$response->headers->set('Vary', 'X-Inertia');
if (! $request->header('X-Inertia')) {
return $response;
}
if ($request->method() === 'GET' && $request->header('X-Inertia-Version', '') !== Inertia::getVersion()) {
$response = $this->onVersionChange($request, $response);
}
if ($response->isOk() && empty($response->getContent())) {
$response = $this->onEmptyResponse($request, $response);
}
if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'])) {
$response->setStatusCode(303);
}
return $response;
}
/**
* Determines what to do when an Inertia action returned with no response.
* By default, we'll redirect the user back to where they came from.
*/
public function onEmptyResponse(Request $request, Response $response): Response
{
return Redirect::back();
}
/**
* Determines what to do when the Inertia asset version has changed.
* By default, we'll initiate a client-side location visit to force an update.
*/
public function onVersionChange(Request $request, Response $response): Response
{
if ($request->hasSession()) {
$request->session()->reflash();
}
return Inertia::location($request->fullUrl());
}
/**
* Resolves and prepares validation errors in such
* a way that they are easier to use client-side.
*
* @return object
*/
public function resolveValidationErrors(Request $request)
{
if (! $request->hasSession() || ! $request->session()->has('errors')) {
return (object) [];
}
return (object) collect($request->session()->get('errors')->getBags())->map(function ($bag) {
return (object) collect($bag->messages())->map(function ($errors) {
return $errors[0];
})->toArray();
})->pipe(function ($bags) use ($request) {
if ($bags->has('default') && $request->header('x-inertia-error-bag')) {
return [$request->header('x-inertia-error-bag') => $bags->get('default')];
}
if ($bags->has('default')) {
return $bags->get('default');
}
return $bags->toArray();
});
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace Inertia;
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\App;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceResponse;
use Illuminate\Support\Facades\Response as ResponseFactory;
class Response implements Responsable
{
use Macroable;
protected $component;
protected $props;
protected $rootView;
protected $version;
protected $viewData = [];
/**
* @param array|Arrayable $props
*/
public function __construct(string $component, $props, string $rootView = 'app', string $version = '')
{
$this->component = $component;
$this->props = $props instanceof Arrayable ? $props->toArray() : $props;
$this->rootView = $rootView;
$this->version = $version;
}
/**
* @param string|array $key
* @param mixed $value
*
* @return $this
*/
public function with($key, $value = null): self
{
if (is_array($key)) {
$this->props = array_merge($this->props, $key);
} else {
$this->props[$key] = $value;
}
return $this;
}
/**
* @param string|array $key
* @param mixed $value
*
* @return $this
*/
public function withViewData($key, $value = null): self
{
if (is_array($key)) {
$this->viewData = array_merge($this->viewData, $key);
} else {
$this->viewData[$key] = $value;
}
return $this;
}
public function rootView(string $rootView): self
{
$this->rootView = $rootView;
return $this;
}
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function toResponse($request)
{
$only = array_filter(explode(',', $request->header('X-Inertia-Partial-Data', '')));
$props = ($only && $request->header('X-Inertia-Partial-Component') === $this->component)
? Arr::only($this->props, $only)
: array_filter($this->props, static function ($prop) {
return ! ($prop instanceof LazyProp);
});
$props = $this->resolvePropertyInstances($props, $request);
$page = [
'component' => $this->component,
'props' => $props,
'url' => $request->getBaseUrl().$request->getRequestUri(),
'version' => $this->version,
];
if ($request->header('X-Inertia')) {
return new JsonResponse($page, 200, ['X-Inertia' => 'true']);
}
return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]);
}
/**
* Resolve all necessary class instances in the given props.
*/
public function resolvePropertyInstances(array $props, Request $request, bool $unpackDotProps = true): array
{
foreach ($props as $key => $value) {
if ($value instanceof Closure) {
$value = App::call($value);
}
if ($value instanceof LazyProp) {
$value = App::call($value);
}
if ($value instanceof PromiseInterface) {
$value = $value->wait();
}
if ($value instanceof ResourceResponse || $value instanceof JsonResource) {
$value = $value->toResponse($request)->getData(true);
}
if ($value instanceof Arrayable) {
$value = $value->toArray();
}
if (is_array($value)) {
$value = $this->resolvePropertyInstances($value, $request, false);
}
if ($unpackDotProps && str_contains($key, '.')) {
Arr::set($props, $key, $value);
unset($props[$key]);
} else {
$props[$key] = $value;
}
}
return $props;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Inertia;
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Response as BaseResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirect;
class ResponseFactory
{
use Macroable;
/** @var string */
protected $rootView = 'app';
/** @var array */
protected $sharedProps = [];
/** @var Closure|string|null */
protected $version;
public function setRootView(string $name): void
{
$this->rootView = $name;
}
/**
* @param string|array|Arrayable $key
* @param mixed $value
*/
public function share($key, $value = null): void
{
if (is_array($key)) {
$this->sharedProps = array_merge($this->sharedProps, $key);
} elseif ($key instanceof Arrayable) {
$this->sharedProps = array_merge($this->sharedProps, $key->toArray());
} else {
Arr::set($this->sharedProps, $key, $value);
}
}
/**
* @param mixed $default
*
* @return mixed
*/
public function getShared(string $key = null, $default = null)
{
if ($key) {
return Arr::get($this->sharedProps, $key, $default);
}
return $this->sharedProps;
}
public function flushShared(): void
{
$this->sharedProps = [];
}
/**
* @param Closure|string|null $version
*/
public function version($version): void
{
$this->version = $version;
}
public function getVersion(): string
{
$version = $this->version instanceof Closure
? App::call($this->version)
: $this->version;
return (string) $version;
}
public function lazy(callable $callback): LazyProp
{
return new LazyProp($callback);
}
/**
* @param array|Arrayable $props
*/
public function render(string $component, $props = []): Response
{
if ($props instanceof Arrayable) {
$props = $props->toArray();
}
return new Response(
$component,
array_merge($this->sharedProps, $props),
$this->rootView,
$this->getVersion()
);
}
/**
* @param string|SymfonyRedirect $url
*/
public function location($url): SymfonyResponse
{
if (Request::inertia()) {
return BaseResponse::make('', 409, ['X-Inertia-Location' => $url instanceof SymfonyRedirect ? $url->getTargetUrl() : $url]);
}
return $url instanceof SymfonyRedirect ? $url : Redirect::away($url);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Inertia;
use LogicException;
use Inertia\Ssr\Gateway;
use ReflectionException;
use Illuminate\Http\Request;
use Inertia\Ssr\HttpGateway;
use Illuminate\Routing\Router;
use Illuminate\View\FileViewFinder;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\TestResponseMacros;
use Illuminate\Support\ServiceProvider as BaseServiceProvider;
use Illuminate\Foundation\Testing\TestResponse as LegacyTestResponse;
class ServiceProvider extends BaseServiceProvider
{
public function register(): void
{
$this->app->singleton(ResponseFactory::class);
$this->app->bind(Gateway::class, HttpGateway::class);
$this->mergeConfigFrom(
__DIR__.'/../config/inertia.php',
'inertia'
);
$this->registerBladeDirectives();
$this->registerRequestMacro();
$this->registerRouterMacro();
$this->registerTestingMacros();
$this->app->bind('inertia.testing.view-finder', function ($app) {
return new FileViewFinder(
$app['files'],
$app['config']->get('inertia.testing.page_paths'),
$app['config']->get('inertia.testing.page_extensions')
);
});
}
public function boot(): void
{
$this->registerConsoleCommands();
$this->publishes([
__DIR__.'/../config/inertia.php' => config_path('inertia.php'),
]);
}
protected function registerBladeDirectives(): void
{
$this->callAfterResolving('blade.compiler', function ($blade) {
$blade->directive('inertia', [Directive::class, 'compile']);
$blade->directive('inertiaHead', [Directive::class, 'compileHead']);
});
}
protected function registerConsoleCommands(): void
{
if (! $this->app->runningInConsole()) {
return;
}
$this->commands([
Commands\CreateMiddleware::class,
Commands\StartSsr::class,
Commands\StopSsr::class,
]);
}
protected function registerRequestMacro(): void
{
Request::macro('inertia', function () {
return (bool) $this->header('X-Inertia');
});
}
protected function registerRouterMacro(): void
{
Router::macro('inertia', function ($uri, $component, $props = []) {
return $this->match(['GET', 'HEAD'], $uri, '\\'.Controller::class)
->defaults('component', $component)
->defaults('props', $props);
});
}
/**
* @throws ReflectionException|LogicException
*/
protected function registerTestingMacros(): void
{
if (class_exists(TestResponse::class)) {
TestResponse::mixin(new TestResponseMacros());
return;
}
// Laravel <= 6.0
if (class_exists(LegacyTestResponse::class)) {
LegacyTestResponse::mixin(new TestResponseMacros());
return;
}
throw new LogicException('Could not detect TestResponse class.');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Inertia\Ssr;
class BundleDetector
{
public function detect()
{
return collect([
config('inertia.ssr.bundle'),
base_path('bootstrap/ssr/ssr.mjs'),
base_path('bootstrap/ssr/ssr.js'),
public_path('js/ssr.js'),
])->filter()->first(function ($path) {
return file_exists($path);
});
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Inertia\Ssr;
interface Gateway
{
/**
* Dispatch the Inertia page to the Server Side Rendering engine.
*/
public function dispatch(array $page): ?Response;
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Inertia\Ssr;
use Exception;
use Illuminate\Support\Facades\Http;
class HttpGateway implements Gateway
{
/**
* Dispatch the Inertia page to the Server Side Rendering engine.
*/
public function dispatch(array $page): ?Response
{
if (! config('inertia.ssr.enabled', true) || ! (new BundleDetector())->detect()) {
return null;
}
$url = str_replace('/render', '', config('inertia.ssr.url', 'http://127.0.0.1:13714')).'/render';
try {
$response = Http::post($url, $page)->throw()->json();
} catch (Exception $e) {
return null;
}
if (is_null($response)) {
return null;
}
return new Response(
implode("\n", $response['head']),
$response['body']
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Inertia\Ssr;
class Response
{
/**
* @var string
*/
public $head;
/**
* @var string
*/
public $body;
/**
* Prepare the Inertia Server Side Rendering (SSR) response.
*/
public function __construct(string $head, string $body)
{
$this->head = $head;
$this->body = $body;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Inertia\Ssr;
use Exception;
class SsrException extends Exception
{
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Inertia\Testing;
use Closure;
use const E_USER_DEPRECATED;
use Illuminate\Support\Traits\Macroable;
use PHPUnit\Framework\Assert as PHPUnit;
use Illuminate\Contracts\Support\Arrayable;
use PHPUnit\Framework\AssertionFailedError;
class Assert implements Arrayable
{
use Concerns\Has;
use Concerns\Matching;
use Concerns\Debugging;
use Concerns\PageObject;
use Concerns\Interaction;
use Macroable;
/** @var string */
private $component;
/** @var array */
private $props;
/** @var string */
private $url;
/** @var string|null */
private $version;
/** @var string */
private $path;
protected function __construct(string $component, array $props, string $url, string $version = null, string $path = null)
{
echo "\033[0;31mInertia's built-in 'Assert' library will be removed in a future version of inertia-laravel:\033[0m\n";
echo "\033[0;31m - If you are seeing this error while using \$response->assertInertia(...), please upgrade to Laravel 8.32.0 or higher.\033[0m\n";
echo "\033[0;31m - If you are using the 'Assert' class directly, please adapt your tests to use the 'AssertableInertia' class instead.\033[0m\n";
echo "\033[0;31mFor more information and questions, please see https://github.com/inertiajs/inertia-laravel/pull/338 \033[0m\n\n";
@trigger_error("Inertia's built-in 'Assert' library will be removed in a future version of inertia-laravel: https://github.com/inertiajs/inertia-laravel/pull/338", E_USER_DEPRECATED);
$this->path = $path;
$this->component = $component;
$this->props = $props;
$this->url = $url;
$this->version = $version;
}
protected function dotPath(string $key): string
{
if (is_null($this->path)) {
return $key;
}
return implode('.', [$this->path, $key]);
}
protected function scope(string $key, Closure $callback): self
{
$props = $this->prop($key);
$path = $this->dotPath($key);
PHPUnit::assertIsArray($props, sprintf('Inertia property [%s] is not scopeable.', $path));
$scope = new self($this->component, $props, $this->url, $this->version, $path);
$callback($scope);
$scope->interacted();
return $this;
}
public static function fromTestResponse($response): self
{
try {
$response->assertViewHas('page');
$page = json_decode(json_encode($response->viewData('page')), true);
PHPUnit::assertIsArray($page);
PHPUnit::assertArrayHasKey('component', $page);
PHPUnit::assertArrayHasKey('props', $page);
PHPUnit::assertArrayHasKey('url', $page);
PHPUnit::assertArrayHasKey('version', $page);
} catch (AssertionFailedError $e) {
PHPUnit::fail('Not a valid Inertia response.');
}
return new self($page['component'], $page['props'], $page['url'], $page['version']);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Inertia\Testing;
use InvalidArgumentException;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Assert as PHPUnit;
use PHPUnit\Framework\AssertionFailedError;
use Illuminate\Testing\Fluent\AssertableJson;
class AssertableInertia extends AssertableJson
{
/** @var string */
private $component;
/** @var string */
private $url;
/** @var string|null */
private $version;
public static function fromTestResponse(TestResponse $response): self
{
try {
$response->assertViewHas('page');
$page = json_decode(json_encode($response->viewData('page')), true);
PHPUnit::assertIsArray($page);
PHPUnit::assertArrayHasKey('component', $page);
PHPUnit::assertArrayHasKey('props', $page);
PHPUnit::assertArrayHasKey('url', $page);
PHPUnit::assertArrayHasKey('version', $page);
} catch (AssertionFailedError $e) {
PHPUnit::fail('Not a valid Inertia response.');
}
$instance = static::fromArray($page['props']);
$instance->component = $page['component'];
$instance->url = $page['url'];
$instance->version = $page['version'];
return $instance;
}
public function component(string $value = null, $shouldExist = null): self
{
PHPUnit::assertSame($value, $this->component, 'Unexpected Inertia page component.');
if ($shouldExist || (is_null($shouldExist) && config('inertia.testing.ensure_pages_exist', true))) {
try {
app('inertia.testing.view-finder')->find($value);
} catch (InvalidArgumentException $exception) {
PHPUnit::fail(sprintf('Inertia page component file [%s] does not exist.', $value));
}
}
return $this;
}
public function url(string $value): self
{
PHPUnit::assertSame($value, $this->url, 'Unexpected Inertia page url.');
return $this;
}
public function version(string $value): self
{
PHPUnit::assertSame($value, $this->version, 'Unexpected Inertia asset version.');
return $this;
}
public function toArray()
{
return [
'component' => $this->component,
'props' => $this->prop(),
'url' => $this->url,
'version' => $this->version,
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Inertia\Testing\Concerns;
trait Debugging
{
public function dump(string $prop = null): self
{
dump($this->prop($prop));
return $this;
}
public function dd(string $prop = null): void
{
dd($this->prop($prop));
}
abstract protected function prop(string $key = null);
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Inertia\Testing\Concerns;
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use PHPUnit\Framework\Assert as PHPUnit;
trait Has
{
protected function count(string $key, int $length): self
{
PHPUnit::assertCount(
$length,
$this->prop($key),
sprintf('Inertia property [%s] does not have the expected size.', $this->dotPath($key))
);
return $this;
}
public function hasAll($key): self
{
$keys = is_array($key) ? $key : func_get_args();
foreach ($keys as $prop => $count) {
if (is_int($prop)) {
$this->has($count);
} else {
$this->has($prop, $count);
}
}
return $this;
}
/**
* @param mixed $value
*
* @return $this
*/
public function has(string $key, $value = null, Closure $scope = null): self
{
PHPUnit::assertTrue(
Arr::has($this->prop(), $key),
sprintf('Inertia property [%s] does not exist.', $this->dotPath($key))
);
$this->interactsWith($key);
if (is_int($value) && ! is_null($scope)) {
$path = $this->dotPath($key);
$prop = $this->prop($key);
if ($prop instanceof Collection) {
$prop = $prop->all();
}
PHPUnit::assertTrue($value > 0, sprintf('Cannot scope directly onto the first entry of property [%s] when asserting that it has a size of 0.', $path));
PHPUnit::assertIsArray($prop, sprintf('Direct scoping is currently unsupported for non-array like properties such as [%s].', $path));
$this->count($key, $value);
return $this->scope($key.'.'.array_keys($prop)[0], $scope);
}
if (is_callable($value)) {
$this->scope($key, $value);
} elseif (! is_null($value)) {
$this->count($key, $value);
}
return $this;
}
public function missingAll($key): self
{
$keys = is_array($key) ? $key : func_get_args();
foreach ($keys as $prop) {
$this->misses($prop);
}
return $this;
}
public function missing(string $key): self
{
$this->interactsWith($key);
PHPUnit::assertNotTrue(
Arr::has($this->prop(), $key),
sprintf('Inertia property [%s] was found while it was expected to be missing.', $this->dotPath($key))
);
return $this;
}
public function missesAll($key): self
{
return $this->missingAll(
is_array($key) ? $key : func_get_args()
);
}
public function misses(string $key): self
{
return $this->missing($key);
}
abstract protected function prop(string $key = null);
abstract protected function dotPath(string $key): string;
abstract protected function interactsWith(string $key): void;
abstract protected function scope(string $key, Closure $callback);
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Inertia\Testing\Concerns;
use Illuminate\Support\Str;
use PHPUnit\Framework\Assert as PHPUnit;
trait Interaction
{
/** @var array */
protected $interacted = [];
protected function interactsWith(string $key): void
{
$prop = Str::before($key, '.');
if (! in_array($prop, $this->interacted, true)) {
$this->interacted[] = $prop;
}
}
public function interacted(): void
{
PHPUnit::assertSame(
[],
array_diff(array_keys($this->prop()), $this->interacted),
$this->path
? sprintf('Unexpected Inertia properties were found in scope [%s].', $this->path)
: 'Unexpected Inertia properties were found on the root level.'
);
}
public function etc(): self
{
$this->interacted = array_keys($this->prop());
return $this;
}
abstract protected function prop(string $key = null);
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Inertia\Testing\Concerns;
use Closure;
use Illuminate\Support\Collection;
use PHPUnit\Framework\Assert as PHPUnit;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceResponse;
trait Matching
{
public function whereAll(array $bindings): self
{
foreach ($bindings as $key => $value) {
$this->where($key, $value);
}
return $this;
}
public function where(string $key, $expected): self
{
$this->has($key);
$actual = $this->prop($key);
if ($expected instanceof Closure) {
PHPUnit::assertTrue(
$expected(is_array($actual) ? Collection::make($actual) : $actual),
sprintf('Inertia property [%s] was marked as invalid using a closure.', $this->dotPath($key))
);
return $this;
}
if ($expected instanceof Arrayable) {
$expected = $expected->toArray();
} elseif ($expected instanceof ResourceResponse || $expected instanceof JsonResource) {
$expected = json_decode(json_encode($expected->toResponse(request())->getData()), true);
}
$this->ensureSorted($expected);
$this->ensureSorted($actual);
PHPUnit::assertSame(
$expected,
$actual,
sprintf('Inertia property [%s] does not match the expected value.', $this->dotPath($key))
);
return $this;
}
protected function ensureSorted(&$value): void
{
if (! is_array($value)) {
return;
}
foreach ($value as &$arg) {
$this->ensureSorted($arg);
}
ksort($value);
}
abstract protected function dotPath(string $key): string;
abstract protected function prop(string $key = null);
abstract public function has(string $key, $value = null, Closure $scope = null);
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Inertia\Testing\Concerns;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use PHPUnit\Framework\Assert as PHPUnit;
trait PageObject
{
public function component(string $value = null, $shouldExist = null): self
{
PHPUnit::assertSame($value, $this->component, 'Unexpected Inertia page component.');
if ($shouldExist || (is_null($shouldExist) && config('inertia.testing.ensure_pages_exist', true))) {
try {
app('inertia.testing.view-finder')->find($value);
} catch (InvalidArgumentException $exception) {
PHPUnit::fail(sprintf('Inertia page component file [%s] does not exist.', $value));
}
}
return $this;
}
protected function prop(string $key = null)
{
return Arr::get($this->props, $key);
}
public function url(string $value): self
{
PHPUnit::assertSame($value, $this->url, 'Unexpected Inertia page url.');
return $this;
}
public function version(string $value): self
{
PHPUnit::assertSame($value, $this->version, 'Unexpected Inertia asset version.');
return $this;
}
public function toArray(): array
{
return [
'component' => $this->component,
'props' => $this->props,
'url' => $this->url,
'version' => $this->version,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Inertia\Testing;
use Closure;
use Illuminate\Testing\Fluent\AssertableJson;
class TestResponseMacros
{
public function assertInertia()
{
return function (Closure $callback = null) {
if (class_exists(AssertableJson::class)) {
$assert = AssertableInertia::fromTestResponse($this);
} else {
$assert = Assert::fromTestResponse($this);
}
if (is_null($callback)) {
return $this;
}
$callback($assert);
return $this;
};
}
public function inertiaPage()
{
return function () {
if (class_exists(AssertableJson::class)) {
return AssertableInertia::fromTestResponse($this)->toArray();
}
return Assert::fromTestResponse($this)->toArray();
};
}
}