feat(relay): render dd() responses properly (#29)

* feat(relay): render `dd()` responses correctly

* wiki(relay): `dd` responses

* test: fix failing test after changing test id

* style: apply TS style fixes

* style: apply php style fixes

* chore: fix types

* build: always run php and js test on PR changes

* test(relay): simplify test

* build: use proper cache key for e2e job

* feat(relay): more assertive dd parser

* build: fix package.json indentation

* build: properly build for `dev` mode

* test: correctly install current branch artifacts for E2E

* test: fail fast for PW tests

* build: don't run php and js tests twice on PRs

* test: PW fixes

* test: correctly build artifacts from branch for E2E

* chore: fix naming

* fix(relay): properly parse some type of closures in `dd`

* test: enable parallel runs for PW

* test(relay): simplify `dd` E2E test
This commit is contained in:
Mazen Touati
2026-01-04 15:57:57 +01:00
committed by GitHub
parent 64ef46a8a4
commit e3b3370ebe
53 changed files with 5698 additions and 142 deletions

View File

@@ -21,6 +21,7 @@ class GenerateIntellisenseCommand extends SymfonyCommand
protected array $intellisenseProviders = [
IntellisenseProviders\AuthorizationTypeIntellisense::class,
IntellisenseProviders\RandomValueGeneratorIntellisense::class,
IntellisenseProviders\DumpValueTypeIntellisense::class,
];
const TARGET_BASE_PATH = '/resources/js/interfaces/generated/';

View File

@@ -0,0 +1,41 @@
<?php
namespace Sunchayn\Nimbus\IntellisenseProviders;
use Illuminate\Support\Str;
use RuntimeException;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums\DumpValueTypeEnum;
/**
* Generates TypeScript types for dump value types from the backend enum.
*/
class DumpValueTypeIntellisense implements IntellisenseContract
{
public const STUB = 'dump-value-types.ts.stub';
public function getTargetFileName(): string
{
return Str::remove('.stub', self::STUB);
}
public function generate(): string
{
$enumCases = [];
foreach (DumpValueTypeEnum::cases() as $case) {
$enumCases[] = sprintf(" %s = '%s',", $case->name, $case->value);
}
$enumContent = implode("\n", $enumCases);
return $this->replaceStubContent($enumContent);
}
private function replaceStubContent(string $enumList): string
{
$stubFile = file_get_contents(__DIR__.'/stubs/'.self::STUB) ?: throw new RuntimeException('Cannot read stub file.');
return str_replace('{{ content }}', rtrim($enumList), $stubFile);
}
}

View File

@@ -0,0 +1,3 @@
export enum DumpValueType {
{{ content }}
}

View File

@@ -7,15 +7,17 @@ use GuzzleHttp\Cookie\SetCookie;
use Illuminate\Container\Container;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Response;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandlerFactory;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
use Sunchayn\Nimbus\Modules\Relay\Responses\DumpAndDieResponse;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\PrintableResponseBody;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class RequestRelayAction
{
@@ -25,6 +27,7 @@ class RequestRelayAction
public const NON_STANDARD_STATUS_CODES = [
419 => 'Method Not Allowed',
DumpAndDieResponse::DUMP_AND_DIE_STATUS_CODE => 'dd()',
];
public function __construct(
@@ -44,10 +47,7 @@ class RequestRelayAction
$start = hrtime(true);
$response = $pendingRequest->send(
method: $requestRelayData->method,
url: $requestRelayData->endpoint,
);
$response = $this->sendPendingRequest($pendingRequest, $requestRelayData);
$durationInMs = $this->calculateDuration($start);
@@ -142,8 +142,26 @@ class RequestRelayAction
*/
private function getStatusTextFromCode(int $statusCode): string
{
$statusCodeToTextMapping = Response::$statusTexts + self::NON_STANDARD_STATUS_CODES;
$statusCodeToTextMapping = SymfonyResponse::$statusTexts + self::NON_STANDARD_STATUS_CODES;
return $statusCodeToTextMapping[$statusCode] ?? 'Non-standard Status Code.';
}
private function sendPendingRequest(PendingRequest $pendingRequest, RequestRelayData $requestRelayData): Response
{
$response = $pendingRequest->send(
method: $requestRelayData->method,
url: $requestRelayData->endpoint,
);
if (! $response->serverError()) {
return $response;
}
if (! str_contains($response->body(), 'Sfdump = window.Sfdump')) {
return $response;
}
return new DumpAndDieResponse($response->toPsrResponse());
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|array>
*/
readonly class ObjectPropertyDto implements Arrayable
{
public function __construct(
public string $visibility,
public ParsedValueDto $value,
) {}
public function toArray(): array
{
return [
'visibility' => $this->visibility,
'value' => $this->value->toArray(),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|array>
*/
class ParseResultDto implements Arrayable
{
/**
* @param ParsedValueDto[] $dumps
*/
public function __construct(
public readonly ?string $source,
public readonly array $dumps,
) {}
public static function empty(): self
{
return new self(
source: null,
dumps: [],
);
}
public function toArray(): array
{
return [
'source' => $this->source,
'dumps' => collect($this->dumps)->toArray(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, int|array>
*/
readonly class ParsedArrayResultDto implements Arrayable
{
/**
* @param array<string, mixed> $items
*/
public function __construct(
public array $items,
public bool $numericallyIndexed,
) {}
public function toArray(): array
{
return [
'items' => collect($this->items)->toArray(),
'length' => count($this->items),
'numericallyIndexed' => $this->numericallyIndexed,
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|null>
*/
readonly class ParsedClosureResultDto implements Arrayable
{
public function __construct(
public string $signature,
public ?string $className,
public ?string $thisReference,
) {}
public function toArray(): array
{
return [
'signature' => $this->signature,
'class' => $this->className,
'this' => $this->thisReference,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|int|null|array>
*/
readonly class ParsedObjectResultDto implements Arrayable
{
/**
* @param ObjectPropertyDto[] $properties
*/
public function __construct(
public ?string $className,
public array $properties,
) {}
public function toArray(): array
{
return [
'class' => $this->className,
'properties' => collect($this->properties)->toArray(),
'propertiesCount' => count($this->properties),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums\DumpValueTypeEnum;
/**
* @implements Arrayable<string, string|float|int|bool|null|array>
*/
readonly class ParsedValueDto implements Arrayable
{
public function __construct(
public DumpValueTypeEnum $type,
public ParsedArrayResultDto|ParsedObjectResultDto|ParsedClosureResultDto|string|float|int|bool|null $value,
) {}
public function toArray(): array
{
return [
'type' => $this->type->value,
'value' => $this->value instanceof Arrayable ? $this->value->toArray() : $this->value,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums;
enum DumpValueTypeEnum: string
{
case Object = 'object';
case Array = 'array';
case String = 'string';
case Constant = 'constant';
case Uninitialized = 'uninitialized';
case Number = 'number';
case Closure = 'closure';
case Unknown = 'unknown';
}

View File

@@ -0,0 +1,384 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ObjectPropertyDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedArrayResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedClosureResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedObjectResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedValueDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParseResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums\DumpValueTypeEnum;
class VarDumpParser
{
private const PATTERN_LARAVEL_COMMENT = '/<span[^>]*style=["\'][^"\']*color:\s*#A0A0A0[^"\']*["\'][^>]*>\s*\/\/\s*(.+?)<\/span>/s';
/**
* Parse raw HTML output containing one or more Symfony dumps
*/
public function parse(string $html): ParseResultDto
{
$html = $this->cleanHtml($html);
$dumpSections = $this->extractDumpSections($html);
if ($dumpSections === []) {
return ParseResultDto::empty();
}
$dumps = [];
foreach ($dumpSections as $dumpSection) {
$cleanHtml = $this->removeCommentFromDump($dumpSection);
$dumps[] = $this->parseValue(trim($cleanHtml));
}
return new ParseResultDto(
source: $this->extractComment($dumpSections[0]),
dumps: $dumps,
);
}
/**
* Clean HTML by removing unnecessary elements
*/
private function cleanHtml(string $html): string
{
// Remove style and script tags
$html = (string) preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html);
$html = (string) preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html);
// Remove ellipsis elements for simpler parsing
return (string) preg_replace('/<([a-z]+)\s+[^>]*class=(["\'])(?:[^"\'>]*\s)?sf-dump-ellipsis[^"\'>]*\2[^>]*>.*?<\/\1>/si', '', $html);
}
/**
* Extract individual dump sections from HTML
*
* @return array<array-key, string>
*/
private function extractDumpSections(string $html): array
{
preg_match_all('/<pre\b[^>]*>\K.*?(?=<\/pre>)/s', $html, $matches);
return $matches[0];
}
/**
* Extract Laravel file/line comment from dump output
*/
private function extractComment(string $html): ?string
{
if (preg_match(self::PATTERN_LARAVEL_COMMENT, $html, $match)) {
return trim($match[1]);
}
return null;
}
/**
* Remove Laravel comment from dump HTML
*/
private function removeCommentFromDump(string $html): string
{
return (string) preg_replace(self::PATTERN_LARAVEL_COMMENT, '', $html);
}
/**
* Detect the root type of a dump value
*/
private function detectRootType(string $html): DumpValueTypeEnum
{
if ($html === '""') {
return DumpValueTypeEnum::String;
}
if ($html === '[]') {
return DumpValueTypeEnum::Array;
}
// Array with size notation: array:3 [
if (preg_match('/^<span\b[^>]*class="?sf-dump-note[^>]*>\s*array:\d+/s', $html)) {
return DumpValueTypeEnum::Array;
}
// Closure (must check before object to avoid confusion with named objects)
if (preg_match('/^<span class="?sf-dump-note[^>]*>Closure\([^)]*\)<\/span>/s', $html)) {
return DumpValueTypeEnum::Closure;
}
// Named Objects (e.g. : ClassName {), or Closures.
if (preg_match('/^<span class="?sf-dump-note[^>]*>[^<]*<\/span>\s*\{/s', $html, $matches)) {
// If there are parenthesis in the matched portion (the beginning of the html) then it is a Closure.
// e.g. <span class="sf-dump-note sf-dump-ellipsization" title="Illuminate\Foundation\Application::environment(...$environments)"></span> {
// e.g. <span class=sf-dump-note>Illuminate\Foundation\Application::environment(...$environments)</span> {
return preg_match('/^.+\([^)]*\)/s', $matches[0])
? DumpValueTypeEnum::Closure
: DumpValueTypeEnum::Object;
}
// Runtime object: {<a...>
if (preg_match('/^\{<a class=sf-dump-ref/s', $html)) {
return DumpValueTypeEnum::Object;
}
// String value
if (preg_match('/^"<span\b[^>]*class=sf-dump-str\b/s', $html)) {
return DumpValueTypeEnum::String;
}
// Uninitialized property (must be before const check).
if (preg_match('/^<span [^>]* title="Uninitialized property">/s', $html)) {
return DumpValueTypeEnum::Uninitialized;
}
// Boolean or null constants
if (preg_match('/^<span\b[^>]*class=sf-dump-const\b/s', $html)) {
return DumpValueTypeEnum::Constant;
}
// Numeric value
if (preg_match('/^<span\b[^>]*class=sf-dump-num\b/s', $html)) {
return DumpValueTypeEnum::Number;
}
return DumpValueTypeEnum::Unknown;
}
/**
* Parse a value based on its detected type
*/
private function parseValue(string $html): ParsedValueDto
{
$dumpValueTypeEnum = $this->detectRootType($html);
$value = match ($dumpValueTypeEnum) {
DumpValueTypeEnum::Object => $this->parseObject($html),
DumpValueTypeEnum::Array => $this->parseArray($html),
DumpValueTypeEnum::String => $this->parseString($html),
DumpValueTypeEnum::Constant => $this->parseConst($html),
DumpValueTypeEnum::Number => $this->parseNumber($html),
DumpValueTypeEnum::Closure => $this->parseClosure($html),
DumpValueTypeEnum::Uninitialized => $this->parseUninitialized($html),
DumpValueTypeEnum::Unknown => null,
};
return new ParsedValueDto(type: $dumpValueTypeEnum, value: $value);
}
/**
* Parse object structure and properties
*/
private function parseObject(string $html): ParsedObjectResultDto
{
$className = $this->extractObjectClassName($html);
// Extract content between { and }
if (! preg_match('/\{<a class=sf-dump-ref[^>]*>[^<]+<\/a><samp[^>]+>(.+)<\/samp>}\n?$/s', $html, $match)) {
// Sometimes items are not listed when a certain depth is reached.
// e.g. Symfony\Component\Routing\CompiledRoute {#369 …8}
return new ParsedObjectResultDto(className: $className, properties: []);
}
$content = trim($match[1], "\n");
// Normalize indentation to simplify parsing
$content = $this->normalizeIndentation($content);
$properties = [];
// Match properties with visibility markers: +/-/# "propertyName": value
$pattern = '/[#\+\-]"?<span class=sf-dump-(public|protected|private)[^>]*>([^<]+)<\/span>"?:\s(.*?)(?=(?:\n[#\+\-](?:<|"<)|\Z))/s';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$visibility = $match[1];
$propertyName = trim($match[2]);
$propertyValueHtml = trim($match[3]);
$properties[$propertyName] = new ObjectPropertyDto(
visibility: $visibility,
value: $this->parseValue($propertyValueHtml),
);
}
return new ParsedObjectResultDto(className: $className, properties: $properties);
}
/**
* Parse array structure and items
*/
private function parseArray(string $html): ParsedArrayResultDto
{
if ($html === '[]') {
return new ParsedArrayResultDto(items: [], numericallyIndexed: true);
}
// Extract content within <samp> tags
if (! preg_match('/^<span\b[^>]*class=sf-dump-note[^>]*>\s*array:\d+\s*<\/span>\s*\[\s*<samp\b[^>]*>(.*?)<\/samp>]$/s', $html, $match)) {
// Sometimes items are not listed when a certain depth is reached.
// e.g. +methods: array:2 [ …2]
return new ParsedArrayResultDto(items: [], numericallyIndexed: true);
}
$content = trim($match[1], "\n");
// Normalize indentation
$content = $this->normalizeIndentation($content);
$items = [];
$isNumerical = true;
// Match key => value pairs (works for both indexed and associative)
$pattern = '/"?<span class=sf-dump-(key|index)[^>]*>([^<]+)<\/span>"?\s=>\s(.*?)(?=(?:\n"?<span class=sf-dump-(?:key|index))|\Z)/s';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$keyType = $match[1]; // 'key' or 'index'
$key = $match[2];
$valueHtml = trim($match[3]);
// If any key is not an index, the array is not numerically indexed
if ($keyType === 'key') {
$isNumerical = false;
}
$items[$key] = $this->parseValue($valueHtml);
}
return new ParsedArrayResultDto(items: $items, numericallyIndexed: $isNumerical);
}
/**
* Parse string primitive value
*/
private function parseString(string $html): string
{
if ($html === '""') {
return '';
}
if (preg_match('/<span class=sf-dump-str[^>]*>([^<]*)<\/span>/', $html, $match)) {
return html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5);
}
return '';
}
/**
* Parse constant value (true, false, null)
*/
private function parseConst(string $html): ?bool
{
if (preg_match('/<span class=sf-dump-const[^>]*>([^<]+)<\/span>/', $html, $match)) {
return match (strtolower(trim($match[1]))) {
'true' => true,
'false' => false,
default => null,
};
}
return null;
}
/**
* Parse numeric value (int or float)
*/
private function parseNumber(string $html): int|float
{
if (preg_match('/<span class=sf-dump-num[^>]*>([^<]+)<\/span>/', $html, $match)) {
$value = $match[1];
return str_contains($value, '.') ? (float) $value : (int) $value;
}
return 0;
}
/**
* Parse closure information
*/
private function parseClosure(string $html): ParsedClosureResultDto
{
$signature = 'CLosure()';
$className = null;
$thisReference = null;
// Extract signature from between span tags.
if (preg_match('/^<span[^>]*>([^<]+)/', $html, $match)) {
$signature = trim($match[1]);
}
// Extract signature from the title
elseif (preg_match('/^<span[^>]*title="([^"\n]+)/', $html, $match)) {
$signature = trim($match[1]);
}
// Extract class context
if (preg_match('/<span [^>]*class=sf-dump-meta\s*>class<\/span>:\s*"?<span [^>]*title="([^"\n]+)/', $html, $match)) {
$className = trim($match[1]);
}
// Extract this reference
if (preg_match('/<span [^>]*class=sf-dump-meta\s*>this<\/span>:\s*"?<span [^>]*title="([^"\n]+)/', $html, $match)) {
$thisReference = trim($match[1]);
}
return new ParsedClosureResultDto(
signature: $signature,
className: $className,
thisReference: $thisReference,
);
}
/**
* Parse uninitialized property annotation
*/
private function parseUninitialized(string $html): string
{
if (preg_match('/^<span[^>]+>([^<]+)<\/span>$/', $html, $match)) {
return $match[1];
}
return 'undefined';
}
/**
* Extract class name from object dump
*/
private function extractObjectClassName(string $html): ?string
{
// Check for title attribute (ellipsized class names)
if (preg_match('/^<span[^>]*title="([^"\n\s]+)/', $html, $match)) {
return trim($match[1]);
}
// Check for class name in sf-dump-note span
if (preg_match('/^<span class="?sf-dump-note[^>]*>([^<]+)<\/span>/s', $html, $match)) {
return html_entity_decode(strip_tags(trim($match[1])), ENT_QUOTES | ENT_HTML5);
}
// Runtime object (no explicit class name)
if (preg_match('/<a class=sf-dump-ref[^>]*>/', $html)) {
return '<runtime object>';
}
return null;
}
/**
* Normalize indentation by removing common leading whitespace
*/
private function normalizeIndentation(string $content): string
{
// Find the indentation of the first line
if (preg_match('/^(\s+)/s', $content, $match)) {
$indent = $match[1];
// Remove this indentation from all lines
return (string) preg_replace("/\n{$indent}/", "\n", ltrim($content));
}
return ltrim($content);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Responses;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\VarDumpParser;
class DumpAndDieResponse extends Response
{
public const DUMP_AND_DIE_STATUS_CODE = 999;
public function getStatusCode(): int
{
return self::DUMP_AND_DIE_STATUS_CODE;
}
/**
* @return array<string, string|array<array-key, mixed>>
*/
public function json($key = null, $default = null): array
{
[
'source' => $source,
'dumps' => $dumps,
] = resolve(VarDumpParser::class)->parse($this->response->getBody())->toArray();
return [
'id' => Str::uuid()->toString(),
'timestamp' => now()->format('Y-m-d H:i:s'),
'source' => $source,
'dumps' => $dumps,
];
}
}