From bd69d77b2a0a76dc24e7afb8241ace193488ca3d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 13:44:54 -0700 Subject: [PATCH 01/78] feat(admin): firefly.admin.table.* settings for page size, scrollport height and density --- packages/admin/src/AdminSettings.php | 6 + packages/admin/src/Table/TableSettings.php | 127 ++++++++++++++++++ .../admin/tests/Table/TableSettingsTest.php | 103 ++++++++++++++ skeleton/config/firefly.php | 53 ++++++++ 4 files changed, 289 insertions(+) create mode 100644 packages/admin/src/Table/TableSettings.php create mode 100644 packages/admin/tests/Table/TableSettingsTest.php diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php index c3b9f1c5..aa59d713 100644 --- a/packages/admin/src/AdminSettings.php +++ b/packages/admin/src/AdminSettings.php @@ -4,6 +4,7 @@ namespace Firefly\Admin; +use Firefly\Admin\Table\TableSettings; use Firefly\Config\Config; /** @@ -33,6 +34,7 @@ public function __construct( public string $theme = 'auto', public int $graphMaxNodes = 220, public array $excludedPages = [], + public TableSettings $table = new TableSettings, ) {} public static function fromConfig(Config $config): self @@ -52,6 +54,10 @@ public static function fromConfig(Config $config): self // depends on the screen and the application. graphMaxNodes: max(0, $config->int('firefly.admin.graph.max-nodes', 220)), excludedPages: self::csv($config->string('firefly.admin.pages.exclude', '')), + // Every listing's paging, sorting and spacing. On AdminSettings rather than resolved separately + // because a Blade view reaches exactly one settings object, and a second one would mean every + // view that draws a table taking a second parameter through render(). + table: TableSettings::fromConfig($config), ); } diff --git a/packages/admin/src/Table/TableSettings.php b/packages/admin/src/Table/TableSettings.php new file mode 100644 index 00000000..031f7434 --- /dev/null +++ b/packages/admin/src/Table/TableSettings.php @@ -0,0 +1,127 @@ +` renders, so the + * configured default is ALWAYS a member of it: a ` has no selected option and the control silently resets the size on submit. +it('adds the configured default to the offered set when the set does not contain it', function () { + $settings = TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['page-size' => 30, 'page-sizes' => '25,50']]], + ])); + + expect($settings->pageSizes)->toBe([25, 30, 50])->and($settings->pageSize)->toBe(30); +}); + +it('drops an offered size that is not a positive integer or exceeds the ceiling', function () { + $settings = TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['page-sizes' => '25, x, -5, 0, 100, 9000', 'max-page-size' => 200]]], + ])); + + expect($settings->pageSizes)->toBe([25, 50, 100]); +}); + +it('caps max-page-size at the hard ceiling the data browser already uses', function () { + $settings = TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['max-page-size' => 1000000]]], + ])); + + expect($settings->maxPageSize)->toBe(TableSettings::PAGE_SIZE_CEILING); +}); + +// A size arrives in a URL an operator can hand-edit. The set is CLOSED rather than merely capped, because +// ?size=199 on a 4 000 000-row listing is a request nobody offered and one the page cannot decline halfway. +it('accepts only an offered size from a caller and otherwise uses the default', function (?int $requested, int $expected) { + expect(TableSettings::fromConfig(tableConfig([]))->clamp($requested))->toBe($expected); +})->with([ + [null, 50], + [25, 25], + [200, 200], + [199, 50], + [1000000, 50], + [0, 50], + [-1, 50], +]); + +// max-height is INTERPOLATED INTO THE STYLESHEET. A value that is not a length is not merely wrong, it is +// a CSS injection vector, so it is matched against a pattern and refused rather than escaped. +it('refuses a max-height that is not a length or none', function (string $configured, string $expected) { + expect(TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['max-height' => $configured]]], + ]))->maxHeight)->toBe($expected); +})->with([ + ['none', 'none'], + ['70vh', '70vh'], + ['480px', '480px'], + ['32rem', '32rem'], + ['70', '68vh'], + ['70vh;}body{display:none', '68vh'], + ['calc(100vh - 200px)', '68vh'], + ['', '68vh'], +]); + +it('reads a compact density as tighter row padding and anything unrecognised as comfortable', function () { + $compact = TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['density' => 'COMPACT']]], + ])); + $odd = TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['density' => 'roomy']]], + ])); + + expect($compact->density)->toBe(TableSettings::DENSITY_COMPACT) + ->and($compact->rowPaddingX())->toBe('10px') + ->and($compact->rowPaddingY())->toBe('5px') + ->and($odd->density)->toBe(TableSettings::DENSITY_COMFORTABLE) + ->and($odd->rowPaddingX())->toBe('14px') + ->and($odd->rowPaddingY())->toBe('8px'); +}); + +it('hangs off AdminSettings so every view reaches it as $settings->table', function () { + expect(AdminSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['page-size' => 100]]], + ]))->table->pageSize)->toBe(100); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 8f090de8..636125f3 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1090,6 +1090,59 @@ // 'max-nodes' => 220, // ], // + // /* + // | THE LISTING TABLES — every page that draws a list of rows + // | + // | Routes, beans, conditions, scheduled tasks, OAuth2 clients, the environment, config properties, + // | caches, loggers, metrics, HTTP traffic and the data browser all render through one table system: + // | a typed column vocabulary laid out with `table-layout:fixed` and an explicit , a + // | header that stays put while the rows scroll, and paging, sorting and searching done on the + // | SERVER. Before this the whole list was rendered into every response and narrowed in the browser, + // | which is fine for eleven rows and is not what 207 auto-configuration conditions look like. + // */ + // 'table' => [ + // /* + // | Rows per page, and the sizes the rows-per-page control offers. `page-size` is always one of + // | `page-sizes` — if you set a default the set does not contain, it is added to the set, + // | because a with no + button, which is a control that does nothing at all for a keyboard, a text browser or a page whose + script failed — on a dashboard whose entire value proposition is working in a network-isolated + environment with no build step. The onchange stays as a convenience; the button is what makes it a + control. +--}} +@php + /** @var \Firefly\Admin\Table\ListingPage $slice */ + /** @var \Firefly\Admin\Table\ListingQuery $query */ + $window = $slice->window(); +@endphp +
+ + {{ number_format($slice->from()) }}–{{ number_format($slice->to()) }} of {{ number_format($slice->total) }} + @if ($slice->isPaged()) · page {{ number_format($slice->page) }} of {{ number_format($slice->lastPage()) }} @endif + +
+ @foreach ($query->hiddenFields() as $field) + @continue ($field['name'] === ($query->qualifier === '' ? 'size' : $query->qualifier.'_size')) + + @endforeach + @if ($query->search !== null)@endif + + +
+ + @if ($slice->isPaged()) + hasPrevious()) href="{{ $slice->link($slice->page - 1) }}" @endif>Previous + @if ($window[0] > 1) + 1 + @if ($window[0] > 2)…@endif + @endif + @foreach ($window as $n) + {{ $n }} + @endforeach + @if (end($window) < $slice->lastPage()) + @if (end($window) < $slice->lastPage() - 1)…@endif + {{ number_format($slice->lastPage()) }} + @endif + hasNext()) href="{{ $slice->link($slice->page + 1) }}" @endif>Next + @endif +
diff --git a/packages/admin/resources/views/_panel-head.blade.php b/packages/admin/resources/views/_panel-head.blade.php index 7648136d..512f7113 100644 --- a/packages/admin/resources/views/_panel-head.blade.php +++ b/packages/admin/resources/views/_panel-head.blade.php @@ -1,8 +1,33 @@ -{{-- A panel header with an optional filter box and a live "shown of total" readout. --}} +{{-- + A panel header with a count, and — depending on what it was given — a way to narrow the rows under it. + + THREE MODES, AND THE ORDER MATTERS BECAUSE THIS PARTIAL IS INCLUDED BY TWENTY VIEWS. With a `query` it + renders a GET search form and the grand total: the narrowing happens on the SERVER and the number is a + fact about the application. With a `filter` it renders the in-page JavaScript row filter and a live + "shown of total": that is still right for a panel whose rows are all in the response by definition — + the resource index, the health indicators, the settings console. With neither it renders the count + alone, which is what overview's four panels, graph's three and five other views ask for. + + `{{ number_format($count) }} total` IS A PINNED STRING. tests/Browser/AdminDataBrowserTest.php asserts + `assertSee('3 total')` and `assertSee('1 total')` against the data browser's listing, which hand-rolled + exactly this label before it moved here. +--}}

{{ $title }}

- @isset($filter) + @isset($query) + + {{ number_format($count) }} total + @elseif (isset($filter)) {{ $count }} diff --git a/packages/admin/resources/views/_table-head.blade.php b/packages/admin/resources/views/_table-head.blade.php new file mode 100644 index 00000000..753af6ba --- /dev/null +++ b/packages/admin/resources/views/_table-head.blade.php @@ -0,0 +1,32 @@ +{{-- + A listing's and , emitted from its column definitions. + + EVERY CALL SITE PASSES BOTH KEYS, and `query` may be null. Blade's @include inherits the including + view's variables, so a partial that read `$query ?? null` would silently pick up a page-level variable + of the same name and start linking one table's headers at another table's state. Reading them bare + means a forgotten key is an undefined-variable error in the capstone suite rather than a wrong page. + + The widths are computed, not declared here: see TableView::widths() for why a fixed layout with an + explicit width per column is the only arrangement that survives a fully-qualified class name. +--}} +@php + /** @var \Firefly\Admin\Table\TableView $view */ + /** @var \Firefly\Admin\Table\ListingQuery|null $query */ + $widths = $view->widths(); +@endphp + + @foreach ($widths as $width)@endforeach + + + + @foreach ($view->columns as $column) + + @if ($query !== null && $column->isSortable()) + {{ $column->label }}{{ $query->indicator($column->key) }} + @else + {{ $column->label }} + @endif + + @endforeach + + diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 9b0eeaa9..d6db3c76 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -56,6 +56,16 @@ --rail:224px; --top:52px; --r:10px; + /* + | THE TABLE METRICS, published as custom properties because a has to do arithmetic with + | them. `--row-x` is the horizontal cell padding, and every rigid column width is emitted as + | `calc(ch + 2 * var(--row-x))` — box-sizing is border-box below, so a bare `ch` would be + | characters MINUS both paddings, which is about 28px of a pill column and is where the + | verb DELETE was being clipped. `--table-vh` is the height of the scroll box a table lives in. + */ + --row-x:{{ $settings->table->rowPaddingX() }}; + --row-y:{{ $settings->table->rowPaddingY() }}; + --table-vh:{{ $settings->table->maxHeight }}; --shadow:0 1px 2px rgba(24,20,12,.05), 0 6px 18px -12px rgba(24,20,12,.25); } html[data-theme="dark"], html[data-theme="auto"] .dark-probe { } @@ -251,36 +261,99 @@ .stat a{color:inherit} /* ── tables ──────────────────────────────────────────────────────── */ - .tw{overflow-x:auto} + .tw{overflow:auto} table{border-collapse:collapse;width:100%;font-size:13px} thead th{ - position:sticky;top:0;z-index:1;text-align:left;padding:8px 14px;background:var(--panel-2); - border-bottom:1px solid var(--line);color:var(--ink-3); + position:sticky;top:0;z-index:1;text-align:left;padding:var(--row-y) var(--row-x);background:var(--panel-2); + color:var(--ink-3); font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;white-space:nowrap; } - tbody td{padding:8px 14px;border-bottom:1px solid var(--line);vertical-align:top} + /* + A BOX-SHADOW, NOT A BORDER. Under `border-collapse:collapse` the border of a sticky belongs + to the TABLE's border grid rather than to the cell, so it stays behind with the rows and the + header scrolls out from under its own underline — the one visual cue that the header is a header. + An inset shadow is painted by the cell and travels with it. + */ + thead th{box-shadow:inset 0 -1px 0 var(--line)} + tbody td{padding:var(--row-y) var(--row-x);border-bottom:1px solid var(--line);vertical-align:top} tbody tr:last-child td{border-bottom:0} tbody tr:hover{background:var(--hover)} td.mono,th.mono{font-family:var(--mono);font-size:12.5px} td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap} td.tight{width:1%;white-space:nowrap} .dim{color:var(--ink-3)} - .wrap{overflow-wrap:anywhere} /* Long free-text cells stop growing past a readable measure instead of stretching the row to the - full window width, which on a 1920 screen put a label at x=270 and its value at x=1855. */ + full window width, which on a 1920 screen put a label at x=270 and its value at x=1855. Both of + these belong to the AUTO-layout tables the overview and the record page still draw — a listing + declares its widths from PHP instead, and neither class appears on a `table.ftable`. */ td.text{max-width:64ch} td.num.pin{width:1%} + /* + `break-word`, NOT `anywhere`, AND THE DIFFERENCE IS THE WHOLE ROUTES BUG. Per CSS Text 3 both + break an unbreakable token at render time, but `anywhere` also CONTRIBUTES its break + opportunities to min-content sizing — so under auto layout a path's minimum width became one + glyph, the layout engine gave it three characters, and `/greetings/{name}` rendered as six + stacked lines beside 1100px of empty column. `break-word` breaks the same token and leaves + intrinsic sizing alone, which is exactly the distinction the code wanted. + */ + .wrap{overflow-wrap:break-word} - /* A fully-qualified class name has no spaces, so overflow-wrap:anywhere breaks it mid-word — - "SecurityHeadersFilte / r". Showing the short name on its own line and eliding the namespace under - it keeps the column scannable: you read class names down the page instead of decoding wraps. */ - /* max-width:0 with width:100% is the standard way to make ONE table cell absorb the slack and - truncate, instead of the longest class name widening the table until the columns beside it are - pushed off the panel — which is what clipped the condition column. */ - .cls{max-width:0;width:100%} - .cls .nm,.cls .ns{display:block;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - .cls .nm{font-size:12.5px} - .cls .ns{font-size:11px;color:var(--ink-3)} + /* + THE TWO-LINE QUALIFIED CELL. A fully-qualified class name has no spaces, so any wrap breaks it + mid-word — "SecurityHeadersFilte / r". Showing the short name on its own line and eliding the + namespace under it keeps the column scannable: you read class names down the page instead of + decoding wraps. + + WHAT WAS WRONG WAS THE WIDTH MECHANISM, NOT THE IDEA. This used to be `.cls{max-width:0;width:100%}` + under a ten-line comment claiming it truncated the namespace with an ellipsis. It never fired: + min/max-width on a table cell is undefined in CSS 2.1 and Chrome ignores it under auto layout, so + the cell computed `max-width:0px` while rendering 853px wide with `scrollWidth === clientWidth` — + nothing was ever clipped, and all the declaration actually did was absorb every pixel of slack and + fling the neighbouring column to the far edge. The truncation now lives on the inner spans, where + it resolves against the definite width the gives the column. + */ + .cls .nm,.cls .ns,td.t-qual .nm,td.t-qual .ns{display:block;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .cls .nm,td.t-qual .nm{font-size:12.5px} + .cls .ns,td.t-qual .ns{font-size:11px;color:var(--ink-3)} + /* + A STEM ELIDES FROM THE LEFT. `App\Http\Controllers\Api\V1` is located by its TAIL — `Api\V1` is + the part that distinguishes it from its neighbours — so clipping the end would remove the only + informative half. An RTL container anchors the overflow at the start; `unicode-bidi:plaintext` + then takes each line's own direction from its first strong character, which for a Latin class + name is LTR, so the text reads normally and only the ellipsis moves. + */ + .ns.stem{direction:rtl;unicode-bidi:plaintext;text-align:left} + + /* ── the listing table ─────────────────────────────────────────────── + ONE MECHANISM. This generalises `table.datatable`, which was already the only real column system + in this sheet — typed classes, widths from the type — into something every listing declares from + PHP. `table-layout:fixed` plus a computed by TableView is what makes it hold: with + `auto` the browser sizes from content and no amount of CSS wins that argument. + */ + table.ftable{table-layout:fixed} + /* `ch` on a resolves against the 's OWN font, which inherits from — 13px sans — + while the cells these widths size are 12.5px mono. Declaring the colgroup's font is what makes + every number in TableColumn mean the character it was counting. */ + table.ftable colgroup{font:12.5px var(--mono)} + table.ftable td,table.ftable th{overflow:hidden} + table.ftable thead th a{display:inline-flex;align-items:center;gap:3px;color:inherit} + table.ftable thead th a:hover{color:var(--accent);text-decoration:none} + table.ftable td.t-pill,table.ftable th.t-pill{white-space:nowrap} + table.ftable td.t-num,table.ftable th.t-num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap} + table.ftable th.t-num a{justify-content:flex-end} + table.ftable td.t-stamp{font-family:var(--mono);font-size:12px;color:var(--ink-3);white-space:nowrap} + table.ftable td.t-meter{vertical-align:middle} + table.ftable td.t-actions{white-space:nowrap} + /* Clipped to one line with the full value on the title: a table whose row height depends on its + longest json blob is not a table. */ + table.ftable td.t-token,table.ftable td.t-path,table.ftable td.t-line{ + font-family:var(--mono);font-size:12.5px;white-space:nowrap;text-overflow:ellipsis; + } + table.ftable td.t-line{color:var(--ink-2)} + table.ftable td.t-text{overflow-wrap:break-word} + table.ftable td.t-qual{font-family:var(--mono)} + /* A sorted header's arrow. One per table: ListingQuery::indicator() returns '' for every other column. */ + th .ord{display:inline-block;width:10px;color:var(--brand)} /* ── verb + status pills ─────────────────────────────────────────── */ .verb{ @@ -360,7 +433,6 @@ table.datatable td.secret .v{color:var(--ink-3);letter-spacing:.08em} .nul{color:var(--ink-3)} .idv{font-weight:650} - th .ord{display:inline-block;width:10px;color:var(--brand)} /* Two states read faster as a shape than as a word. */ .bool{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600} diff --git a/packages/admin/resources/views/mappings.blade.php b/packages/admin/resources/views/mappings.blade.php index ed4d27bb..9f8ac5b4 100644 --- a/packages/admin/resources/views/mappings.blade.php +++ b/packages/admin/resources/views/mappings.blade.php @@ -11,31 +11,33 @@
@include('firefly-admin::_panel-head', [ - 'title' => 'Mappings', 'count' => count($mappings), - 'filter' => 'map-body', 'placeholder' => 'Filter by path or handler…', + 'title' => 'Mappings', 'count' => $slice->total, 'query' => $query, + 'placeholder' => 'Search by path or handler…', ]) - @if ($mappings === []) - @include('firefly-admin::_empty', [ - 'title' => 'No routes mapped', - 'body' => 'Create one with php artisan make:firefly-controller, then re-run firefly:cache if this application boots compiled.', - ]) + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $query->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No route\'s path, handler or name contains that. Show them all.'] + : ['title' => 'No routes mapped', 'body' => 'Create one with php artisan make:firefly-controller, then re-run firefly:cache if this application boots compiled.']) @else
-
- - - @foreach ($mappings as $route) - @php $handler = is_string($route['handler'] ?? null) ? $route['handler'] : ''; @endphp +
MethodPathHandlerName
+ @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) + + @foreach ($slice->rows as $route) - - - - + + + + @endforeach
{{ $route['httpMethod'] ?? '' }}{{ $route['path'] ?? '' }}{{ Format::shortClass($handler) }}{{ rtrim(Format::namespaceOf($handler), '\\') }}{{ $route['name'] ?: '—' }}{{ $route['httpMethod'] }}{{ $route['path'] }} + {{ Format::leafOf($route['handler']) }} + {{ Format::stemOf($route['handler']) }} + {{ $route['name'] ?: '—' }}
+ @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) @endif @endsection diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index bf44944a..801cc8f2 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -17,6 +17,11 @@ use Firefly\Admin\Format; use Firefly\Admin\Settings\FeatureToggle; use Firefly\Admin\Settings\SettingsConsole; +use Firefly\Admin\Table\InMemoryListing; +use Firefly\Admin\Table\ListingPage; +use Firefly\Admin\Table\ListingQuery; +use Firefly\Admin\Table\TableColumn; +use Firefly\Admin\Table\TableView; use Firefly\Context\Scan\AppScan; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\View\Factory as ViewFactory; @@ -104,7 +109,7 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse : $this->html($this->render('data-disabled', []), 404); } - return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($slug), $current), 200); + return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($request, $slug), $current), 200); } private function settingsPage(AdminPage $current): SymfonyResponse @@ -407,8 +412,16 @@ private function filters(Request $request): array : []; } - /** @return array */ - private function data(string $slug): array + /** + * The model for one page. + * + * IT TAKES THE REQUEST, AND UNTIL THIS WAVE IT DID NOT. `__invoke` had one and this method did not, so + * no page in the dashboard could read a query parameter — which is why every listing rendered its whole + * payload into the response and narrowed it with a keyup handler. It is private with one caller. + * + * @return array + */ + private function data(Request $request, string $slug): array { return match ($slug) { '' => $this->overview(), @@ -424,7 +437,19 @@ private function data(string $slug): array $this->subArray($this->payload('configprops'), 'beans'), )], 'conditions' => $this->payload('conditions') + ['positiveMatches' => [], 'negativeMatches' => []], - 'mappings' => ['mappings' => $this->listOf('mappings', 'mappings')], + 'mappings' => $this->listing( + $request, + 'mappings', + $this->mappingRows(), + TableView::of( + TableColumn::pill('httpMethod', 'Method'), + TableColumn::path('path', 'Path', weight: 5), + TableColumn::qualified('handler', 'Handler', weight: 4), + TableColumn::token('name', 'Name', weight: 3), + ), + ['path', 'handler', 'name'], + 'path', + ), 'scheduled' => ['tasks' => $this->listOf('scheduledtasks', 'tasks')], 'oauth2' => $this->oauth2(), 'env' => ['env' => $this->flatten($this->subArray($this->payload('env'), 'firefly'), 'firefly')], @@ -442,6 +467,87 @@ private function data(string $slug): array }; } + /** + * One page of an actuator payload, plus the query that produced it. + * + * The view is handed the TableView as well, because the columns decide THREE things at once and they + * must not be able to disagree: the widths in the , the headers, and which keys `?sort=` will + * accept. Passing `$view->sortable()` into the query is what stops a hand-edited `?sort=password` + * ordering by something the page does not draw. + * + * `$defaultSort` is left null for a listing whose natural order IS its tiebreak: InMemoryListing falls + * back to the tiebreak column when no sort was asked for, so declaring the same key as the default sort + * would change nothing about the rows and a great deal about the URLs — `meaningful()` omits a + * parameter that is already at its default, so every header link would lose its own `?sort=` and a + * reader could not tell the sorted column from the rest by looking at where the link goes. + * + * GENERIC OVER THE ROW, the way ListingPage is: a caller that hands in a precisely-shaped + * `list` gets that shape back in `$slice->rows`, so the view draws `$route['path']` without + * re-asserting that it is a string and PHPStan at level max has nothing to complain about. + * + * @template TRow of array + * + * @param list $rows + * @param list $searchable + * @return array{query: ListingQuery, slice: ListingPage, view: TableView} + */ + private function listing( + Request $request, + string $slug, + array $rows, + TableView $view, + array $searchable, + string $tiebreak, + ?string $defaultSort = null, + string $defaultDirection = 'asc', + string $qualifier = '', + ): array { + $query = ListingQuery::fromRequest( + $request, + $this->settings->table, + $this->settings->url($slug), + $view->sortable(), + defaultSort: $defaultSort, + defaultDirection: $defaultDirection, + qualifier: $qualifier, + ); + + return [ + 'query' => $query, + 'slice' => InMemoryListing::page($rows, $query, $searchable, $tiebreak), + 'view' => $view, + ]; + } + + /** + * The route table as rows the listing engine can sort and search. + * + * Normalised here rather than in the view for two reasons: an actuator payload is `array` and + * PHPStan at level max is right to insist somebody say otherwise, and a sort over `$route['path']` has + * to compare strings rather than "whatever the endpoint put there" — one int in that column and + * strnatcasecmp is comparing a number to a name. + * + * @return list + */ + private function mappingRows(): array + { + $rows = []; + foreach ($this->listOf('mappings', 'mappings') as $route) { + if (! is_array($route)) { + continue; + } + + $rows[] = [ + 'httpMethod' => is_string($route['httpMethod'] ?? null) ? $route['httpMethod'] : '', + 'path' => is_string($route['path'] ?? null) ? $route['path'] : '', + 'handler' => is_string($route['handler'] ?? null) ? $route['handler'] : '', + 'name' => is_string($route['name'] ?? null) ? $route['name'] : '', + ]; + } + + return $rows; + } + /** * The overview is the page an operator leaves open, so it answers the three questions that matter * without a click: is it healthy, what is it doing, and what did it wire. diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php new file mode 100644 index 00000000..e1b6ca1d --- /dev/null +++ b/packages/admin/tests/AdminTableListingTest.php @@ -0,0 +1,77 @@ +get('/firefly/mappings')->assertStatus(200)->getContent(); + + expect($body)->toContain('') + ->toContain('') + // The pill column's width carries BOTH cell paddings, because box-sizing is border-box here and a + // bare 7.5ch is 7.5 characters minus 28px — which is where DELETE went. + ->toContain('') + ->toContain('calc((100% - (7.5ch + 1 * 2 * var(--row-x)))'); +}); + +it('types every cell of the Routes table by the vocabulary', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/mappings')->assertStatus(200)->getContent(); + + expect($body)->toContain('class="t-pill"') + ->toContain('class="t-path"') + ->toContain('class="t-qual"') + ->toContain('class="t-token"') + // A path is discriminated by its head, so nothing about it is elided from the left. + ->not->toContain('class="ns stem">get('/firefly/mappings')->assertSee('href="/firefly/mappings?sort=path"', false); + + $this->get('/firefly/mappings?sort=path&q=orders') + ->assertStatus(200) + ->assertSee('href="/firefly/mappings?q=orders&sort=path&dir=desc"', false); +}); + +// The complaint under the complaint: the filter was a keyup handler over rows that were all already in the +// response. It is a GET form now, and the narrowing is a fact about the application rather than the DOM. +it('searches on the server and says how many rows matched', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/mappings?q=orders') + ->assertStatus(200) + ->assertSee('name="q"', false) + ->assertDontSee('data-filter="map-body"', false) + // The absent row is named by its HANDLER, not by its path: the sheet is inline on this page and + // the comment above `.wrap` quotes `/greetings/{name}` as the bug it exists to fix, so asserting + // the raw path absent would be asserting something about a CSS comment. + ->assertDontSee('GreetingController', false); +}); + +it('renders one page at a time and a pager that carries the search', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/mappings?size=25&page=1') + ->assertStatus(200) + // The rows-per-page control must work with scripts off: it had onchange="this.form.submit()" and + // no button at all, which is a control that silently does nothing for a keyboard or a text browser. + ->assertSee('Rows', false) + ->assertSee('type="submit"', false); +}); + +it('clamps a page past the end onto the last one rather than rendering an empty table', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/mappings?page=9999&size=25')->assertStatus(200)->assertSee('Mappings', false); +}); + +it('says nothing matches, and offers a way back, when a search empties the listing', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/mappings?q=zzzzzzzz') + ->assertStatus(200) + ->assertSee('Nothing matches', false) + ->assertSee('href="/firefly/mappings"', false); +}); diff --git a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php new file mode 100644 index 00000000..041cb7e9 --- /dev/null +++ b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php @@ -0,0 +1,44 @@ +instance(RouteManifest::class, new RouteManifest([ + new RouteDescriptor('GET', '/greetings/{name}', 'App\Http\GreetingController', 'show', 200, 'greetings.show', []), + new RouteDescriptor('GET', '/', 'App\Http\WelcomeController', 'index', 200, 'welcome', [], html: true), + new RouteDescriptor('GET', '/orders', 'App\Http\OrderController', 'index', 200, 'orders.index', []), + new RouteDescriptor('GET', '/orders/{id}', 'App\Http\OrderController', 'show', 200, 'orders.show', []), + new RouteDescriptor('POST', '/orders', 'App\Http\OrderController', 'store', 201, 'orders.store', []), + new RouteDescriptor('PUT', '/orders/{id}', 'App\Http\OrderController', 'update', 200, 'orders.update', []), + new RouteDescriptor('DELETE', '/orders/{id}', 'App\Http\OrderController', 'destroy', 204, 'orders.destroy', []), + ])); + } +} diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php new file mode 100644 index 00000000..1e4bef1b --- /dev/null +++ b/tests/Browser/AdminTablesTest.php @@ -0,0 +1,102 @@ +extend(AdminDashboardBrowserTestCase::class); + +/** + * THE COMPLAINT, MEASURED. With seven routes on the page, `/greetings/{name}` rendered as six stacked lines + * of three or four characters beside 1100px of empty column, because `overflow-wrap:anywhere` made the + * path's min-content width one glyph under auto layout. A path cell is now one line high. + */ +it('draws a route path on one line instead of six', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/mappings') + ->assertScript(<<<'JS' + (() => { + const cell = [...document.querySelectorAll('td.t-path')].find(c => c.textContent.includes('/greetings/')); + if (cell === undefined) { return 'no /greetings/ row'; } + // LINE BOXES, NOT THE CELL'S HEIGHT. Every cell in a row is as tall as the row, and the + // row is two lines tall because the Handler cell draws the leaf over its stem — so the + // path cell's own box says nothing about how the path was laid out. A Range over the + // cell's contents reports one rectangle per line box, which is the thing being counted: + // it was six, and it is one. + const range = document.createRange(); + range.selectNodeContents(cell); + return range.getClientRects().length === 1; + })() + JS, true) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'admin-mappings'); +}); + +/** + * THE PADDING TRAP, MEASURED. `box-sizing:border-box` is global on this page, so a 7.5ch pill column is + * 7.5 characters MINUS 2×14px — about 34px of content, in which DELETE clips. The width is emitted as + * `calc(7.5ch + 2 * var(--row-x))`, and this is the assertion that says so in pixels. + */ +it('does not clip the verb DELETE in the method column', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/mappings') + ->assertScript(<<<'JS' + (() => { + const pill = [...document.querySelectorAll('td.t-pill .verb')].find(v => v.textContent.trim() === 'DELETE'); + if (pill === undefined) { return 'no DELETE row'; } + const cell = pill.closest('td'); + return pill.scrollWidth <= pill.clientWidth && cell.scrollWidth <= cell.clientWidth; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); + +// The handler column used to absorb every spare pixel through `.cls{max-width:0;width:100%}` and fling the +// Name column to the far edge. Every column now has a declared width, and none of them is the whole table. +it('gives every column a declared width and none of them the whole table', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/mappings') + ->assertScript(<<<'JS' + (() => { + const table = document.querySelector('table.ftable'); + const cols = [...table.querySelectorAll('col')]; + if (cols.length !== 4) { return 'expected four cols, got ' + cols.length; } + const widths = [...table.querySelectorAll('thead th')].map(th => th.getBoundingClientRect().width); + return getComputedStyle(table).tableLayout === 'fixed' + && widths.every(w => w > 40) + && Math.max(...widths) < table.getBoundingClientRect().width * 0.6; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); + +// A namespace is located by its TAIL, so the overflow is anchored at the start of the stem. +it('elides a handler namespace from the left, and its short name from the right', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/mappings') + ->assertScript(<<<'JS' + (() => { + const stem = document.querySelector('td.t-qual .ns.stem'); + const name = document.querySelector('td.t-qual .nm'); + return getComputedStyle(stem).direction === 'rtl' + && getComputedStyle(name).direction === 'ltr' + && getComputedStyle(stem).textOverflow === 'ellipsis'; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); + +it('searches and sorts on the server, from links that keep the rest of the state', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/mappings') + ->fill('q', 'orders') + ->press('Search') + ->assertQueryStringHas('q', 'orders') + ->assertSee('OrderController') + ->assertDontSee('GreetingController') + ->click('Path') + ->assertQueryStringHas('sort', 'path') + ->assertQueryStringHas('q', 'orders') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'admin-mappings-searched'); +}); From 4667c7c3ced737f4d220abb90d4453cab1067e7e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 15:03:56 -0700 Subject: [PATCH 15/78] feat(web): the error report carries the 405's verbs, the authored sentence and a hard frame budget, and the reference documents both new keys --- packages/web/src/Error/ErrorPageSettings.php | 9 ++ packages/web/src/Error/ErrorReport.php | 89 +++++++++++++++++++- packages/web/src/Error/ProblemMapper.php | 43 ++++++++++ packages/web/tests/Error/ErrorPageTest.php | 65 ++++++++++++++ skeleton/config/firefly.php | 27 ++++++ 5 files changed, 231 insertions(+), 2 deletions(-) diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 62ebacf8..218377de 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -86,6 +86,11 @@ public function __construct( string $signIn = '', string $support = '', public bool $actions = true, + // HOW MANY FRAMES THE PAGE BUILDS AT ALL. Applied as a trim in ErrorReport, before markup: a page + // that renders a hundred frames and hides ninety has still escaped and shipped a hundred. + public int $maxFrames = 40, + // Whether the production page uses the sentence the problem document publishes as its lede. + public bool $authoredDetail = true, ) { $this->home = self::url($home); $this->signIn = self::url($signIn); @@ -158,6 +163,10 @@ public static function fromConfig(Config $config): self signIn: $config->string('firefly.web.error-page.sign-in', ''), support: $config->string('firefly.web.error-page.support', ''), actions: $config->bool('firefly.web.error-page.actions', true), + // Clamped rather than trusted, like excerpt-lines above it: 0 would render a trace with no + // frames in it, and a million would put the 10,108-pixel page back. + maxFrames: max(1, min(500, $config->int('firefly.web.error-page.max-frames', 40))), + authoredDetail: $config->bool('firefly.web.error-page.authored-detail', true), ); } diff --git a/packages/web/src/Error/ErrorReport.php b/packages/web/src/Error/ErrorReport.php index b71b8aad..d50a386a 100644 --- a/packages/web/src/Error/ErrorReport.php +++ b/packages/web/src/Error/ErrorReport.php @@ -46,6 +46,7 @@ * * @param list $frames * @param list $previous + * @param list $allowed */ private function __construct( public int $status, @@ -64,17 +65,36 @@ private function __construct( public array $previous = [], public string $reference = '', public string $correlationId = '', + /** @var list */ + public array $allowed = [], + public string $publicDetail = '', + public int $frameCount = 0, + public int $appFrameCount = 0, ) {} public static function of(Throwable $e, Request $request, ErrorPageSettings $settings, string $basePath, int $status, string $reason, string $timestamp): self { - $payload = ErrorResponse::fromException(ProblemMapper::toFireflyException($e), instance: $request->path(), timestamp: $timestamp)->toArray(); + $payload = ErrorResponse::fromException(ProblemMapper::toFireflyException($e), instance: ProblemMapper::instanceFor($request), timestamp: $timestamp)->toArray(); // The reference is the id a person can act on: the W3C trace id when this request has one, the // correlation id otherwise. The correlation id is carried beside it, never replaced by it. $reference = TraceContext::referenceFor($request); $correlationId = CorrelationIdFilter::of($request); + // The verbs a 405 permits are already parsed, HEAD-filtered and published as an extension member; + // the page threw them away and shrugged instead. Read with an explicit is_array + foreach + + // is_string loop rather than array_filter, which cannot give PHPStan at level max a list. + $allowed = []; + if (is_array($payload['allowed'] ?? null)) { + foreach ($payload['allowed'] as $method) { + if (is_string($method)) { + $allowed[] = $method; + } + } + } + + $publicDetail = $settings->authoredDetail ? ProblemMapper::authoredDetail($e) : ''; + $public = new self( status: $status, reason: $reason, @@ -87,6 +107,8 @@ public static function of(Throwable $e, Request $request, ErrorPageSettings $set detailed: false, reference: $reference, correlationId: $correlationId, + allowed: $allowed, + publicDetail: $publicDetail, ); if (! $settings->trace) { @@ -95,6 +117,17 @@ public static function of(Throwable $e, Request $request, ErrorPageSettings $set $roots = SourcePaths::roots($e, $basePath); + // Built once, counted, then budgeted — three statements rather than one expression, because the + // counts describe the UNTRIMMED stack and the page needs both numbers to say "8 of 104 frames · 10 + // in your code" without lying about either half. + $frames = self::frames($e, $roots, $settings->excerptLines); + $appFrames = 0; + foreach ($frames as $frame) { + if (! $frame->vendor) { + $appFrames++; + } + } + return new self( status: $public->status, reason: $public->reason, @@ -108,13 +141,65 @@ public static function of(Throwable $e, Request $request, ErrorPageSettings $set exceptionClass: $e::class, message: $e->getMessage(), location: SourcePaths::shorten($e->getFile(), $roots).':'.$e->getLine(), - frames: self::frames($e, $roots, $settings->excerptLines), + frames: self::budget($frames, $settings->maxFrames), previous: self::previous($e, $roots), reference: $reference, correlationId: $correlationId, + allowed: $allowed, + publicDetail: $publicDetail, + frameCount: count($frames), + appFrameCount: $appFrames, ); } + /** + * The frames the page will actually build, in stack order. + * + * A HARD TRIM, NOT A STYLE. The alternative — render every frame and hide the tail with CSS — keeps a + * hundred frames in the DOM that a screen reader still walks and a find-in-page still matches, and + * costs the same hundred escapes on a page that renders while the application is already failing. + * + * YOUR FRAMES ARE NEVER WHAT GETS TRIMMED. Taking the first N would drop an application frame sixty + * deep — a controller called from a queue worker, a listener under the event dispatcher — which is + * precisely the frame a reader opened this page for. So the budget is spent on application frames + * first and filled with vendor frames in stack order, and the result is still in stack order because + * both passes walk the same list. + * + * @param list $frames + * @return list + */ + private static function budget(array $frames, int $max): array + { + if (count($frames) <= $max) { + return $frames; + } + + $keep = []; + + foreach ($frames as $i => $frame) { + if (! $frame->vendor && count($keep) < $max) { + $keep[$i] = true; + } + } + + foreach (array_keys($frames) as $i) { + if (count($keep) >= $max) { + break; + } + + $keep[$i] = true; + } + + $kept = []; + foreach ($frames as $i => $frame) { + if (isset($keep[$i])) { + $kept[] = $frame; + } + } + + return $kept; + } + /** * The throw site first, then the call stack — which is the order a reader wants and the opposite of the * order `getTrace()` returns it in relative to `getFile()`. PHP's trace starts at the CALLER of the diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php index 38fa1c32..ddcaeb76 100644 --- a/packages/web/src/Error/ProblemMapper.php +++ b/packages/web/src/Error/ProblemMapper.php @@ -9,6 +9,7 @@ use Firefly\Kernel\Error\ErrorResponse; use Firefly\Kernel\Error\ErrorSeverity; use Firefly\Kernel\Exception\FireflyException; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; @@ -94,6 +95,48 @@ public static function statusText(int $status): string return $texts[$status] ?? 'HTTP Error'; } + /** + * The sentence this failure may say to whoever asked, or '' when it has none. + * + * ONE RULE, ASKED TWICE. The HTML page and the problem document describe the same failure, and until + * this existed they described it differently: the document published "Order 42 does not exist." — the + * application's own sentence, which is the entire point of the taxonomy — while the page beside it said + * "That page does not exist." A person reading the page and an operator reading the log were looking at + * two different errors. + * + * The gate is the STATUS and the KIND of throwable, not the class hierarchy. Below 500 a + * FireflyException's message was written FOR the client, and an HttpExceptionInterface's is the author's + * when abort() supplied one — a judge caught that `abort(404, 'No such tenant.')` raises an + * HttpException and not a FireflyException, so testing for the taxonomy alone would withhold exactly the + * sentences an application took the trouble to write. At 500 and above nothing is authored: a + * QueryException's message is the failing SQL and its bindings, and toFireflyException() has already + * replaced it with the opaque one. The mapping is reused rather than restated so the router's 404 and + * 405 sentences come out here exactly as they go onto the wire. + */ + public static function authoredDetail(Throwable $e): string + { + if (! $e instanceof FireflyException && ! $e instanceof HttpExceptionInterface) { + return ''; + } + + $mapped = self::toFireflyException($e, disclose: false); + + return $mapped->httpStatus() < 500 ? $mapped->getMessage() : ''; + } + + /** + * The request path as an RFC 9457 `instance`: a ROOT-RELATIVE reference, leading slash and all. + * + * `$request->path()` answers `orders/42`, and a relative reference is resolved against the document's + * base URI — which for a problem served from /api/orders/42 makes `api/orders/42` mean + * /api/api/orders/42. One character, and the member stops identifying the occurrence it exists to + * identify. Spring's ProblemDetail sets `instance` from the request URI for the same reason. + */ + public static function instanceFor(Request $request): string + { + return '/'.ltrim($request->path(), '/'); + } + /** * An HttpException's message is the author's when abort() supplied one and the router's when the router * raised it. Laravel's router has phrased its 404 as "The route {uri} could not be found." for its whole diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 9fcbb156..0b0e8b6e 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Config\Config; use Firefly\Kernel\Exception\Business\ResourceNotFoundException; use Firefly\Web\Error\ErrorPage; use Firefly\Web\Error\ErrorPageRenderer; @@ -10,7 +11,9 @@ use Firefly\Web\Error\ProblemMapper; use Firefly\Web\Exception\ProblemDetailsRenderer; use Firefly\Web\Trace\TraceContext; +use Illuminate\Config\Repository; use Illuminate\Http\Request; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -393,3 +396,65 @@ expect($package)->toMatch('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#'); } }); + +it('carries the verbs a 405 accepts, which the problem document already had and the page threw away', function () { + $settings = new ErrorPageSettings(trace: false, hints: false); + $e = new MethodNotAllowedHttpException(['POST', 'HEAD'], 'The GET method is not supported for route orders. Supported methods: POST, HEAD.'); + $error = ErrorReport::of($e, Request::create('/orders', 'GET'), $settings, dirname(__DIR__, 4), 405, 'Method Not Allowed', '2026-01-01T00:00:00+00:00'); + + // HEAD is dropped where the sentence is built, not here: Symfony adds it beside every GET and no person + // chooses it. It stays on the Allow header, where the standard wants it. + expect($error->allowed)->toBe(['POST']) + ->and($error->method)->toBe('GET'); +}); + +it('carries the authored sentence for a sub-500 failure, so the page and the document say the same words', function () { + $settings = new ErrorPageSettings(trace: false, hints: false); + $business = new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'); + $error = ErrorReport::of($business, Request::create('/orders/42'), $settings, dirname(__DIR__, 4), 404, 'Not Found', '2026-01-01T00:00:00+00:00'); + + // An abort(404, '…') raises an HttpException, NOT a FireflyException — the taxonomy is not the test of + // whether a sentence was authored, the status and the kind of throwable are. + $aborted = ErrorReport::of(new NotFoundHttpException('No such tenant.'), Request::create('/t/9'), $settings, dirname(__DIR__, 4), 404, 'Not Found', '2026-01-01T00:00:00+00:00'); + + // And a generic throwable's message is an accident — a table name, a bound value, a path on the server. + $accident = ErrorReport::of(new RuntimeException('SQLSTATE[42S02]: no such table'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + + expect($error->publicDetail)->toBe('Order 42 does not exist.') + ->and($aborted->publicDetail)->toBe('No such tenant.') + ->and($accident->publicDetail)->toBe('') + // The router's own sentence is replaced by the product's, exactly as it is in problem+json. + ->and(ErrorReport::of(new NotFoundHttpException('The route nope could not be found.'), Request::create('/nope'), $settings, dirname(__DIR__, 4), 404, 'Not Found', '2026-01-01T00:00:00+00:00')->publicDetail) + ->toBe(ProblemMapper::NOTHING_HERE); +}); + +it('trims the stack to the configured budget BEFORE markup, and never trims your own frames away', function () { + // The budget is an array_slice in the report, not a CSS trick in the page: a page that renders a hundred + // frames and hides ninety of them has still built, escaped and shipped a hundred frames, and the DOM a + // screen reader walks is still a hundred long. + $settings = new ErrorPageSettings(trace: true, maxFrames: 6); + $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + + $appKept = count(array_filter($error->frames, static fn ($f): bool => ! $f->vendor)); + $appTotal = $error->appFrameCount; + + expect($error->frames)->toHaveCount(6) + ->and($error->frameCount)->toBeGreaterThan(6) + // The counts describe the UNTRIMMED stack, so the page can say "6 of 104" honestly. + ->and($appTotal)->toBeGreaterThan(0) + ->and($appKept)->toBe(min($appTotal, 6)) + // Order is the stack's, still: the throw site is first whatever the budget dropped. + ->and($error->frames[0]->call)->toBe('throw') + ->and($error->frames[0]->index)->toBe(0); +}); + +it('clamps an absurd budget rather than trusting it', function () { + $config = static fn (int $max): ErrorPageSettings => ErrorPageSettings::fromConfig( + new Config(new Repository(['firefly' => ['web' => ['error-page' => ['max-frames' => $max]]]])), + ); + + expect($config(0)->maxFrames)->toBe(1) + ->and($config(-7)->maxFrames)->toBe(1) + ->and($config(100000)->maxFrames)->toBe(500) + ->and(ErrorPageSettings::fromConfig(new Config(new Repository))->maxFrames)->toBe(40); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 4dfdee70..0ec52de6 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -942,6 +942,33 @@ // 'excerpt-lines' => 7, // // /* + // | How many stack frames the page BUILDS, clamped to 1-500. It is a hard trim taken in the + // | report before any markup exists, not a style rule that hides the tail: a page that renders + // | a hundred frames and hides ninety has still escaped and shipped a hundred, and the DOM a + // | screen reader walks is still a hundred long. The budget is spent on YOUR frames first and + // | filled with dependency frames in stack order, so a controller sixty frames deep under a + // | queue worker is never the one that gets dropped, and the page still says how many frames + // | the untrimmed stack had. + // | + // | Default: 40. + // */ + // 'max-frames' => 40, + // + // /* + // | Whether the page may say the sentence the problem document already publishes — the + // | application's own "Order 42 does not exist." for a sub-500 failure, which is the entire + // | point of the exception taxonomy. With it off the page falls back to the generic sentence + // | for the status, and one failure reads two ways depending on which surface answered. + // | + // | Only sentences that were WRITTEN for a caller are ever used: below 500, from a + // | FireflyException or from an `abort(404, '…')`. At 500 and above nothing is authored — a + // | QueryException's message is the failing SQL and its bindings — and nothing is published. + // | + // | Default: true. + // */ + // 'authored-detail' => env('FIREFLY_WEB_ERROR_PAGE_AUTHORED_DETAIL', true), + // + // /* // | WHERE A READER CAN GO NEXT. The page offers the action that fits the status — a 401 gets // | `sign-in`, a 5xx gets "Try again" (a plain link to the same path), everything gets `home` // | and `support` when they are set — and offers nothing it was not given: no route name is From afce35afeeb3fe1975809db23676d0a4e17c932c Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 15:20:12 -0700 Subject: [PATCH 16/78] fix(web): withhold the 404 sentences Laravel itself generates, and state the page's disclosure contract once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authoredDetail()` was publishing framework-generated 404 messages as if an author had written them. `httpMessage()` replaced only the router's "The route … could not be found.", but Handler::prepareException() rewrites a ModelNotFoundException and a BackedEnumCaseNotFoundException into `new NotFoundHttpException($e->getMessage(), $e)` BEFORE renderViaCallbacks() reaches this package's renderable, so a missed route binding — the most ordinary 404 an application has — put "No query results for model [App\Models\Order] 42" into problem+json's `detail` and onto the report the production page reads as its lede. The rule is added where the router's already lives, so one branch fixes both surfaces, and the docblock names each shape's provenance. ErrorPageSettings stated the disclosure contract two contradictory ways: the class docblock said production with `trace` off sees the status, reason and code and explicitly "not the message", while the new `authored-detail` property two screens below defaulted to true and claimed the page prints a sentence. The "WHAT PRODUCTION SEES" paragraph now carves out the one exception and names its gate, and the property comment describes what the REPORT carries rather than page behaviour that arrives in a later task. --- packages/web/src/Error/ErrorPageSettings.php | 25 +++++++--- packages/web/src/Error/ProblemMapper.php | 46 +++++++++++++++---- .../web/tests/CapstoneWebIntegrationTest.php | 22 +++++++++ packages/web/tests/Error/ErrorPageTest.php | 28 +++++++++++ .../Exception/ProblemDetailsRendererTest.php | 23 ++++++++++ .../web/tests/Fixtures/BoomController.php | 22 +++++++++ 6 files changed, 150 insertions(+), 16 deletions(-) diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 218377de..8e1bb759 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -36,11 +36,22 @@ * person who wants a driver message inside a JSON `detail` says so with `firefly.web.problem.disclose=true`; * nothing infers it. The two gates are independent so that turning one on never opens the other. * - * WHAT PRODUCTION SEES with `trace` off is the status, the reason phrase and the stable error code — the - * same `code` the problem+json carries, so a user can quote it into a support ticket and an operator can - * find it in the log. Not the message: an exception message is written for a developer and routinely names - * a table, a column, a class or an id. And not the footer's own advice about turning the trace on, which is - * useful on a staging box and is a free hint about the stack to anyone else — see `hints`. + * WHAT PRODUCTION SEES with `trace` off is the status, the reason phrase, the stable error code and the + * request's reference — the same `code` and `traceId` the problem+json carries, so a user can quote them + * into a support ticket and an operator can find them in the log. Never the RAW exception message: that + * sentence was written for a developer and routinely names a table, a column, a class or an id. And not the + * footer's own advice about turning the trace on, which is useful on a staging box and is a free hint about + * the stack to anyone else — see `hints`. + * + * THE ONE ADDITION IS AUTHORED, AND IT HAS ITS OWN KEY. With `authored-detail` on (the default) a sub-500 + * failure also carries the sentence the application ITSELF wrote for a caller — a FireflyException's "Order + * 42 does not exist.", or an `abort(404, 'No such tenant.')` — because that is exactly what the problem + * document beside it publishes as `detail`, and one failure reading two ways depending on which surface + * answered is its own kind of bug. It is not an exemption from the paragraph above: ProblemMapper decides + * what counts as authored, withholds everything at 500 and above, and replaces the sentences the FRAMEWORK + * generated — the router's "The route … could not be found." and the route-model-binding 404s Laravel + * rewrites into it, which name a model class and a primary key. Turn the key off and there is no authored + * sentence to print at all. */ final readonly class ErrorPageSettings { @@ -89,7 +100,9 @@ public function __construct( // HOW MANY FRAMES THE PAGE BUILDS AT ALL. Applied as a trim in ErrorReport, before markup: a page // that renders a hundred frames and hides ninety has still escaped and shipped a hundred. public int $maxFrames = 40, - // Whether the production page uses the sentence the problem document publishes as its lede. + // Whether the report CARRIES the sentence the problem document publishes, so a page can use it as + // its lede. Off, `ErrorReport::$publicDetail` is '' and there is nothing for a renderer to print. + // What counts as authored is ProblemMapper's decision, not this key's — see the class comment. public bool $authoredDetail = true, ) { $this->home = self::url($home); diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php index ddcaeb76..e90ff2d3 100644 --- a/packages/web/src/Error/ProblemMapper.php +++ b/packages/web/src/Error/ProblemMapper.php @@ -29,11 +29,13 @@ * limit is named next, because a reader can act on it: the request was not wrong, the server stopped it, * and a 503 with `Retry-After` says so where a 500 with the engine's sentence says "your fault, no idea * why". A Symfony/Illuminate HttpExceptionInterface keeps its REAL status; its message is the author's when - * `abort(404, '…')` supplied one, and the ROUTER's when the router raised it — and the router's sentences + * `abort(404, '…')` supplied one, and the FRAMEWORK's otherwise — and every framework-generated sentence * ("The route api/x could not be found.", "The GET method is not supported for route api/x. Supported - * methods: POST.") are replaced with ones written for a person, because "route" is the framework's word, - * the path is already in `instance`, and the allowed methods belong in an `allowed` extension member and - * the `Allow` header, not inside a sentence a client would have to parse. + * methods: POST.", and the route-model-binding 404s Laravel's handler rewrites into the router's own + * exception class) is replaced with one written for a person, because "route" is the framework's word, the + * path is already in `instance`, a model class and a primary key are nobody's business but the log's, and + * the allowed methods belong in an `allowed` extension member and the `Allow` header, not inside a sentence + * a client would have to parse. See httpMessage() for which shapes are generated and where each comes from. * * ANYTHING ELSE IS AN ACCIDENT, AND ITS MESSAGE IS NOT FOR THE CLIENT. A QueryException stringifies the * failing SQL *and its bindings*; a TypeError names an absolute path on the server; a PDOException names the @@ -110,8 +112,10 @@ public static function statusText(int $status): string * HttpException and not a FireflyException, so testing for the taxonomy alone would withhold exactly the * sentences an application took the trouble to write. At 500 and above nothing is authored: a * QueryException's message is the failing SQL and its bindings, and toFireflyException() has already - * replaced it with the opaque one. The mapping is reused rather than restated so the router's 404 and - * 405 sentences come out here exactly as they go onto the wire. + * replaced it with the opaque one. The mapping is reused rather than restated so the sentences the + * FRAMEWORK generates — the router's 404 and 405, and the route-model-binding 404s Laravel rewrites + * into the router's own exception class — come out here exactly as they go onto the wire, withheld on + * both surfaces or published on both. */ public static function authoredDetail(Throwable $e): string { @@ -138,10 +142,25 @@ public static function instanceFor(Request $request): string } /** - * An HttpException's message is the author's when abort() supplied one and the router's when the router - * raised it. Laravel's router has phrased its 404 as "The route {uri} could not be found." for its whole - * life; that exact shape is the only one replaced, so `abort(404, 'No such tenant.')` still reaches the - * client verbatim. + * An HttpException's message is the AUTHOR's when abort() supplied one and the FRAMEWORK's when the + * framework raised it, and only the framework's is replaced — so `abort(404, 'No such tenant.')` still + * reaches the client verbatim. The replacement lives here, in the one mapping both renderers go + * through, so the problem document's `detail` and the page's lede are fixed by a single rule and cannot + * come to disagree about the same 404. + * + * THREE GENERATED SHAPES, AND TWO OF THEM DISCLOSE. Laravel's router has phrased its miss as "The route + * {uri} could not be found." for its whole life; "route" is the framework's word for something the + * caller never named, and the path is already in `instance`. The other two do not come from the router + * at all: Handler::prepareException() rewrites a ModelNotFoundException and a + * BackedEnumCaseNotFoundException into `new NotFoundHttpException($e->getMessage(), $e)` BEFORE any + * renderable callback is consulted, which puts "No query results for model [App\Models\Order] 42" and + * "Case [pending] not found on Backed Enum [App\Enums\Status]." — an application FQCN and a primary + * key — on the wire to whoever followed a stale link. A missed route-model binding is the most ORDINARY + * 404 an application has, so leaving these two out would make the disclosing case the common one. + * + * Laravel's remaining rewrites are deliberately left alone because they name nothing: a + * RecordsNotFoundException becomes a flat "Not found.", a RequestExceptionInterface a flat "Bad + * request.", and either is already a sentence a person can read. */ private static function httpMessage(HttpExceptionInterface $e): string { @@ -155,6 +174,13 @@ private static function httpMessage(HttpExceptionInterface $e): string return self::NOTHING_HERE; } + if ($e->getStatusCode() === 404 && ( + str_starts_with($message, 'No query results for model [') + || (str_starts_with($message, 'Case [') && str_contains($message, '] not found on Backed Enum [')) + )) { + return self::NOTHING_HERE; + } + return $message; } diff --git a/packages/web/tests/CapstoneWebIntegrationTest.php b/packages/web/tests/CapstoneWebIntegrationTest.php index 94df64bc..a3eaf0e0 100644 --- a/packages/web/tests/CapstoneWebIntegrationTest.php +++ b/packages/web/tests/CapstoneWebIntegrationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Web\Error\ProblemMapper; use Firefly\Web\Tests\Support\WebCapstoneTestCase; use Symfony\Component\HttpFoundation\Response; @@ -147,3 +148,24 @@ ->and($detail)->toContain($traceId) ->and($detail)->not->toContain('kaboom'); }); + +it('withholds the 404 sentence LARAVEL generates, after its own handler has rewritten the exception', function () { + /** @var WebCapstoneTestCase $this */ + // The whole point of driving this through the real pipeline: Handler::prepareException() turns + // BoomController::enumCase()'s BackedEnumCaseNotFoundException into a plain NotFoundHttpException + // carrying "Case [pending] not found on Backed Enum [App\Enums\Status]." BEFORE renderViaCallbacks() + // reaches LaraFly's renderable — a ModelNotFoundException, the ordinary route-model-binding miss, takes + // the identical path. A unit test that constructs the mapper's input by hand cannot prove that ordering; + // this one fails the moment Laravel moves the rewrite or changes the wording. + $response = $this->getJson('/boom/enum-case'); + + $response->assertStatus(404) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'RESOURCE_NOT_FOUND') + ->assertJsonPath('detail', ProblemMapper::NOTHING_HERE); + + // The class name is the disclosure, so the assertion is against the RAW body and not the decoded member: + // a sentence smuggled into `title` or an extension would satisfy the path assertion above. + expect((string) $response->getContent())->not->toContain('Enums') + ->and((string) $response->getContent())->not->toContain('Backed Enum'); +}); diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 0b0e8b6e..af5a11c6 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -13,6 +13,7 @@ use Firefly\Web\Trace\TraceContext; use Illuminate\Config\Repository; use Illuminate\Http\Request; +use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -428,6 +429,33 @@ ->toBe(ProblemMapper::NOTHING_HERE); }); +it('never publishes the 404 sentences LARAVEL generates, which name a model class and a primary key', function () { + // Handler::prepareException() rewrites a ModelNotFoundException and a BackedEnumCaseNotFoundException + // into `new NotFoundHttpException($e->getMessage(), $e)` before any renderable callback runs, so what + // arrives here is an ordinary 404 carrying the FRAMEWORK's sentence and indistinguishable by class from + // an author's abort(404, '…'). Only its SHAPE tells them apart. The model sentence is spelled as a + // literal because firefly/web does not depend on illuminate/database — on this path the string IS the + // interface — while the enum one is taken from the real class, which illuminate/routing supplies. + $settings = new ErrorPageSettings(trace: false, hints: false); + $detail = static fn (string $message): string => ErrorReport::of( + new NotFoundHttpException($message), + Request::create('/orders/42'), + $settings, + dirname(__DIR__, 4), + 404, + 'Not Found', + '2026-01-01T00:00:00+00:00', + )->publicDetail; + + expect($detail('No query results for model [App\Models\Order] 42'))->toBe(ProblemMapper::NOTHING_HERE) + // With no ids Laravel ends the sentence with a period instead; both spellings name the class. + ->and($detail('No query results for model [App\Models\Order].'))->toBe(ProblemMapper::NOTHING_HERE) + ->and($detail((new BackedEnumCaseNotFoundException('App\Enums\Status', 'pending'))->getMessage())) + ->toBe(ProblemMapper::NOTHING_HERE) + // And an author's own 404 still stands: this is a test of three generated shapes, not a gag on 404s. + ->and($detail('No such tenant.'))->toBe('No such tenant.'); +}); + it('trims the stack to the configured budget BEFORE markup, and never trims your own frames away', function () { // The budget is an array_slice in the report, not a CSS trick in the page: a page that renders a hundred // frames and hides ninety of them has still built, escaped and shipped a hundred frames, and the DOM a diff --git a/packages/web/tests/Exception/ProblemDetailsRendererTest.php b/packages/web/tests/Exception/ProblemDetailsRendererTest.php index fbb0bd67..720716fe 100644 --- a/packages/web/tests/Exception/ProblemDetailsRendererTest.php +++ b/packages/web/tests/Exception/ProblemDetailsRendererTest.php @@ -13,6 +13,7 @@ use Firefly\Web\Trace\TraceContext; use Illuminate\Config\Repository; use Illuminate\Http\Request; +use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; use Symfony\Component\ErrorHandler\Error\FatalError; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -207,6 +208,28 @@ ->and($router->getMessage())->toBe(ProblemMapper::NOTHING_HERE); }); +it('keeps a model class and a primary key out of `detail` when Laravel rewrote the 404 itself', function () { + // The router is not the only source of a generated 404. Handler::prepareException() turns a + // ModelNotFoundException — the ordinary route-model-binding miss, the most common 404 an application + // has — into `new NotFoundHttpException($e->getMessage(), $e)` before renderViaCallbacks() is reached, + // so this document was publishing an application FQCN and a row id to whoever followed a stale link. + $response = (new ProblemDetailsRenderer)->render( + new NotFoundHttpException('No query results for model [App\Models\Order] 42'), + Request::create('/orders/42'), + ); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true); + + expect($response->getStatusCode())->toBe(404) + ->and($payload['detail'])->toBe(ProblemMapper::NOTHING_HERE) + // Asserted against the RAW body too: a sentence smuggled into another member is the same leak. + ->and((string) $response->getContent())->not->toContain('Models') + ->and(ProblemMapper::toFireflyException( + new NotFoundHttpException((new BackedEnumCaseNotFoundException('App\Enums\Status', 'pending'))->getMessage()), + )->getMessage())->toBe(ProblemMapper::NOTHING_HERE); +}); + it('spreads a FireflyException\'s extension members and title into the document', function () { $response = (new ProblemDetailsRenderer)->render( (new PaymentRequiredException('The Team edition includes up to five workers.', 'EDITION_LIMIT')) diff --git a/packages/web/tests/Fixtures/BoomController.php b/packages/web/tests/Fixtures/BoomController.php index 79e92d4b..d3536677 100644 --- a/packages/web/tests/Fixtures/BoomController.php +++ b/packages/web/tests/Fixtures/BoomController.php @@ -7,12 +7,16 @@ use Firefly\Web\Attributes\GetMapping; use Firefly\Web\Attributes\RequestMapping; use Firefly\Web\Attributes\RestController; +use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; use RuntimeException; /** * Throws a GENERIC (non-FireflyException) Throwable so the capstone can prove the RFC-7807 renderable's * expectsJson() gate: a generic error renders as problem+json ONLY when the request wants JSON; otherwise it * falls through to Laravel's default handler. Its own base path (/boom) keeps it clear of /balances/{id}. + * + * It also throws the one exception Laravel's OWN handler rewrites on the way to a renderable, so the + * capstone can prove what reaches the wire after Handler::prepareException() has had it — see enumCase(). */ #[RestController] #[RequestMapping('/boom')] @@ -24,4 +28,22 @@ public function generic(): array { throw new RuntimeException('kaboom'); } + + /** + * A route-model-binding miss, as the framework raises it. + * + * Handler::prepareException() rewrites this into `new NotFoundHttpException($e->getMessage(), $e)` + * BEFORE renderViaCallbacks() consults anything LaraFly registered, so the renderer sees a plain 404 + * carrying the FRAMEWORK's sentence — "Case [pending] not found on Backed Enum [App\Enums\Status]." — + * and cannot tell it from an author's `abort(404, '…')` by class. BackedEnumCaseNotFoundException is + * thrown rather than ModelNotFoundException, which takes the identical path, because it lives in + * illuminate/routing (a declared dependency of this package) and the other in illuminate/database. + * + * @return array + */ + #[GetMapping('/enum-case')] + public function enumCase(): array + { + throw new BackedEnumCaseNotFoundException('App\Enums\Status', 'pending'); + } } From e3ca8b31da2fab36963133b763d2386c963b3dd2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 15:22:07 -0700 Subject: [PATCH 17/78] test(admin): cover the pager's paged branch and make the page clamp observable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pager's paged branch — the page window, Previous/Next, the first/last jumps, the gaps and the on/off classes — never rendered under test. AdminTableCapstoneTestCase seeds seven routes and the smallest size the rows-per-page control offers is 25, so lastPage() was 1 and isPaged() false in every unit and browser test; _pager.blade.php is included by one view, so nothing else covered it either. That is precisely the third of the partial that carries listing state across a page boundary, which is the bug class this wave exists to remove. AdminTablePagedCapstoneTestCase gives it a fixture that pages: the skeleton's seven routes plus fourteen filler ones, and a page size of 2 offered through firefly.admin.table.page-sizes. The size is seeded in configOverrides() rather than set inside a test because AdminRouteRegistrar builds AdminSettings (and the TableSettings on it) during the boot passes and binds it as an instance — a config()->set() from a test body arrives after the object that would have read it. Twenty-one rows at two a page is eleven pages, the smallest listing on which a five-page window has a gap and a jump on both sides at once. AdminTablePagerTest asserts the window, the marked current page, the hrefless ends, both elisions and — the point of the whole partial — that every page link keeps ?q= across the boundary. Four mutations were checked against it: dropping ListingPage::pageFor(), dropping q from ListingPage::link(), widening window()'s radius and losing the off class each turn one of the new cases red. The clamp test was assertion-free: its only assertion was assertSee('Mappings'), which _panel-head prints on every branch including the empty state, so deleting the clamp left the suite green. It now asserts that the request renders rows and a pager rather than the empty state, and AdminTablePagerTest asserts which page it landed on — a one-page listing cannot tell clamping to the last page from clamping to the first. The three conditional class attributes in the partial became ternaries in the house style, so the markup they emit is class="act on" rather than class="act on " and the assertions against it are not pinned to whitespace Blade happens to leave behind. --- .../admin/resources/views/_pager.blade.php | 12 ++- .../admin/tests/AdminTableListingTest.php | 11 ++- packages/admin/tests/AdminTablePagerTest.php | 92 +++++++++++++++++++ .../Support/AdminTableCapstoneTestCase.php | 17 +++- .../AdminTablePagedCapstoneTestCase.php | 60 ++++++++++++ 5 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 packages/admin/tests/AdminTablePagerTest.php create mode 100644 packages/admin/tests/Support/AdminTablePagedCapstoneTestCase.php diff --git a/packages/admin/resources/views/_pager.blade.php b/packages/admin/resources/views/_pager.blade.php index 89b10659..3e3ec956 100644 --- a/packages/admin/resources/views/_pager.blade.php +++ b/packages/admin/resources/views/_pager.blade.php @@ -14,6 +14,12 @@ script failed — on a dashboard whose entire value proposition is working in a network-isolated environment with no build step. The onchange stays as a convenience; the button is what makes it a control. + + THE PAGED BRANCH AT THE BOTTOM IS THE STATE-CARRYING ONE, and it renders only when there is more than + one page — so a seven-row fixture cannot see it at all, and a page link that silently dropped `q` or + `sort` would widen the listing back to every row with nothing failing. Every link it draws goes through + ListingPage::link(), and packages/admin/tests/AdminTablePagerTest.php asserts that over a fixture built + to page (AdminTablePagedCapstoneTestCase: twenty-one rows, a page size of 2, eleven pages). --}} @php /** @var \Firefly\Admin\Table\ListingPage $slice */ @@ -43,20 +49,20 @@ @if ($slice->isPaged()) - hasPrevious()) href="{{ $slice->link($slice->page - 1) }}" @endif>Previous @if ($window[0] > 1) 1 @if ($window[0] > 2)…@endif @endif @foreach ($window as $n) - {{ $n }} + {{ $n }} @endforeach @if (end($window) < $slice->lastPage()) @if (end($window) < $slice->lastPage() - 1)…@endif {{ number_format($slice->lastPage()) }} @endif - hasNext()) href="{{ $slice->link($slice->page + 1) }}" @endif>Next @endif diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index e1b6ca1d..b900ec74 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -63,9 +63,18 @@ ->assertSee('type="submit"', false); }); +// `assertSee('Mappings')` proves NOTHING here, which is worth writing down: _panel-head prints the panel's +// title on every branch, including the empty one, so that assertion holds whether or not the clamp works. +// What tells a clamped request from an unclamped one is that array_slice() past the end returns no rows at +// all — drop ListingPage::pageFor() from InMemoryListing::page() and this renders the empty state, with no +// rows and no pager. (Which page it landed ON needs a listing with more than one; see AdminTablePagerTest.) it('clamps a page past the end onto the last one rather than rendering an empty table', function () { /** @var AdminTableCapstoneTestCase $this */ - $this->get('/firefly/mappings?page=9999&size=25')->assertStatus(200)->assertSee('Mappings', false); + $this->get('/firefly/mappings?page=9999&size=25') + ->assertStatus(200) + ->assertSee('OrderController', false) + ->assertSee('1–7 of 7', false) + ->assertDontSee('No routes mapped', false); }); it('says nothing matches, and offers a way back, when a search empties the listing', function () { diff --git a/packages/admin/tests/AdminTablePagerTest.php b/packages/admin/tests/AdminTablePagerTest.php new file mode 100644 index 00000000..3c72f7f7 --- /dev/null +++ b/packages/admin/tests/AdminTablePagerTest.php @@ -0,0 +1,92 @@ +get('/firefly/mappings?q=orders&size=2&page=2')->assertStatus(200)->getContent(); + + expect($body)->toContain('page 2 of 3') + // The page that was asked for is the page that is marked, and its own link is a link to itself. + ->toContain('2') + // EVERY jump keeps `q`. This is the assertion the whole partial exists for: the views this replaced + // concatenated the search into each href by hand, and a link that forgot it widened the listing back + // to all twenty-one rows — which reads as rows appearing from nowhere, and fails nothing. + ->toContain('3') + ->toContain('1') + ->toMatch('~Next~') + ->toMatch('~Previous~') + // And the rows under it are still the narrowed ones on page 2, not merely on page 1. + ->toContain('OrderController') + ->not->toContain('WidgetController'); +}); + +// `page=1` and `page=11` are the two positions where a control has nowhere to go, and an anchor that keeps +// its href there is a link to the page the reader is already on. +it('marks the ends of the listing, and gives the dead control no href at all', function () { + /** @var AdminTablePagedCapstoneTestCase $this */ + $first = (string) $this->get('/firefly/mappings?size=2')->assertStatus(200)->getContent(); + + expect($first)->toContain('page 1 of 11') + ->toMatch('~Previous~') + ->toContain('1') + ->toMatch('~Next~'); + + $last = (string) $this->get('/firefly/mappings?size=2&page=11')->assertStatus(200)->getContent(); + + expect($last)->toContain('page 11 of 11') + ->toMatch('~Next~') + ->toMatch('~Previous~'); +}); + +// Eleven pages is the smallest listing on which a five-page window has a gap AND a jump on both sides. +it('draws a window around the current page, keeps the two ends, and says the rest is elided', function () { + /** @var AdminTablePagedCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/mappings?size=2&page=6')->assertStatus(200)->getContent(); + + expect($body)->toContain('page 6 of 11') + ->toContain('6') + // Two either side, and NOT a third: rendering every page of a long listing is a control nobody can use. + ->toContain('>4') + ->toContain('>8') + ->not->toContain('>3') + ->not->toContain('>9') + // The two jumps people actually make are kept whatever the window is. + ->toContain('1') + ->toContain('11'); + + expect(substr_count($body, '…'))->toBe(2); +}); + +/** + * The clamp, where it is actually observable. + * + * AdminTableListingTest asserts the same request renders rows rather than the empty state; it cannot assert + * WHICH page it landed on, because a seven-row listing has one page and clamping to the last is + * indistinguishable from clamping to the first. Eleven pages tells the two apart. + */ +it('clamps a page past the end onto the last page, not back onto the first', function () { + /** @var AdminTablePagedCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/mappings?page=9999&size=2')->assertStatus(200)->getContent(); + + expect($body)->toContain('page 11 of 11') + ->toContain('21–21 of 21') + // The last row by path, so the response is the END of the listing and not its beginning. + ->toContain('/widgets/21') + ->not->toContain('No routes mapped') + // Previous points one back from the page that was RENDERED — 10, never 9998. + ->toMatch('~Previous~'); +}); diff --git a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php index 041cb7e9..6313bfe4 100644 --- a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php +++ b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php @@ -24,6 +24,11 @@ * shipped skeleton in Chromium and asserts the same facts in pixels: `/greetings/{name}` (the path that * rendered as six stacked lines), `DELETE /orders/{id}` (the verb that clipped), and a handful of orders * routes for `?q=orders` to narrow to. Two assertions about one application beat two applications. + * + * THE ROWS ARE A HOOK, not a literal in defineFireflyEnvironment, because seven routes cannot page: the + * smallest size the rows-per-page control offers by default is 25, so `lastPage()` is 1 and the pager's + * whole paged branch — the window, Previous/Next, the first/last jumps — never renders. A subclass that + * needs more than one page overrides `routes()` and appends; see AdminTablePagedCapstoneTestCase. */ abstract class AdminTableCapstoneTestCase extends AdminCapstoneTestCase { @@ -31,7 +36,15 @@ protected function defineFireflyEnvironment(Application $app): void { parent::defineFireflyEnvironment($app); - $app->instance(RouteManifest::class, new RouteManifest([ + $app->instance(RouteManifest::class, new RouteManifest($this->routes())); + } + + /** + * @return list + */ + protected function routes(): array + { + return [ new RouteDescriptor('GET', '/greetings/{name}', 'App\Http\GreetingController', 'show', 200, 'greetings.show', []), new RouteDescriptor('GET', '/', 'App\Http\WelcomeController', 'index', 200, 'welcome', [], html: true), new RouteDescriptor('GET', '/orders', 'App\Http\OrderController', 'index', 200, 'orders.index', []), @@ -39,6 +52,6 @@ protected function defineFireflyEnvironment(Application $app): void new RouteDescriptor('POST', '/orders', 'App\Http\OrderController', 'store', 201, 'orders.store', []), new RouteDescriptor('PUT', '/orders/{id}', 'App\Http\OrderController', 'update', 200, 'orders.update', []), new RouteDescriptor('DELETE', '/orders/{id}', 'App\Http\OrderController', 'destroy', 204, 'orders.destroy', []), - ])); + ]; } } diff --git a/packages/admin/tests/Support/AdminTablePagedCapstoneTestCase.php b/packages/admin/tests/Support/AdminTablePagedCapstoneTestCase.php new file mode 100644 index 00000000..c20f75a3 --- /dev/null +++ b/packages/admin/tests/Support/AdminTablePagedCapstoneTestCase.php @@ -0,0 +1,60 @@ +set()` from a test body + * arrives after the object that would have read it. Twenty-one rows at two per page is eleven pages, which + * is the smallest listing on which a five-page window has a gap and a jump on BOTH sides at once. + * + * The filler is deliberately `/widgets/…`: `?q=orders` must keep narrowing to the order routes the parent + * seeds, so no filler path, handler or name may contain the term the search tests look for. + */ +abstract class AdminTablePagedCapstoneTestCase extends AdminTableCapstoneTestCase +{ + /** Eleven pages at two rows a page — see the class docblock for why eleven and not four. */ + public const int ROWS = 21; + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.table.page-sizes' => '2,25,50', + ]; + } + + /** + * @return list + */ + protected function routes(): array + { + $routes = parent::routes(); + + // Zero-padded, so the natural ordering the listing applies to the path column and the plain string + // ordering of a reader's expectation are the same list — `/widgets/2` would sort after `/widgets/14` + // under one of them and before it under the other, and a page-boundary assertion would be a coin toss. + for ($n = count($routes) + 1; $n <= self::ROWS; $n++) { + $suffix = str_pad((string) $n, 2, '0', STR_PAD_LEFT); + $routes[] = new RouteDescriptor('GET', '/widgets/'.$suffix, 'App\Http\WidgetController', 'show', 200, 'widgets.show.'.$suffix, []); + } + + return $routes; + } +} From ea8fc2d8c1bbf48bfb5240dec1267a4a23601c9a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 15:35:15 -0700 Subject: [PATCH 18/78] fix(web): the trace header names the untrimmed stack, and the reference stops promising what the page does not print yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame budget trimmed the list and the header went on counting the rows it was left with, so a hundred-frame stack rendered as "7 of 40 in your code" with no marker where sixty-four frames had been dropped — a label that was honest before max-frames existed. It reads the untrimmed totals the report already carries and names them whenever they differ from what is shown. Two documents also claimed more than this tree does. firefly.web.error-page.authored-detail puts the authored sentence on the report and nothing prints it: the reference and the ErrorPageSettings class comment said the PAGE falls back to a generic sentence when the key is off, which is what the page does either way. They now say the sentence is carried (and read today by an application's own error view, which is handed the report), in the voice the home/sign-in/support block already uses for values that arrive before their renderer. ProblemMapper::instanceFor() likewise described an RFC 9457 defect as closed while ProblemDetailsRenderer still publishes the relative form; its docblock says so, and the method gains the direct test it never had. --- packages/web/src/Error/ErrorPage.php | 20 +++++++---- packages/web/src/Error/ErrorPageSettings.php | 23 ++++++++----- packages/web/src/Error/ProblemMapper.php | 8 +++++ packages/web/tests/Error/ErrorPageTest.php | 36 ++++++++++++++++++++ skeleton/config/firefly.php | 15 +++++--- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index ff32b82c..7396f371 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -133,21 +133,27 @@ private static function previous(ErrorReport $report): string return $html.''; } + /** + * THE HEADER COUNTS THE STACK, NOT THE ROWS. `max-frames` trims the list in the report, before any + * markup exists, so counting what is rendered would answer "7 of 40 in your code" for a stack of 104 + * and say nothing at all about the sixty-four frames that were dropped — a label that was honest + * before the budget existed and became a quiet lie the moment it did. The untrimmed totals are carried + * on the report for exactly this, and are named whenever they differ from what is shown; when nothing + * was trimmed the "of" is left out, because "40 of 40" is a question a reader should not have to ask. + */ private static function frames(ErrorReport $report): string { if ($report->frames === []) { return ''; } - $app = 0; - foreach ($report->frames as $frame) { - if (! $frame->vendor) { - $app++; - } - } + $shown = count($report->frames); + $summary = $shown === $report->frameCount + ? $report->frameCount.' frames · '.$report->appFrameCount.' in your code' + : $shown.' of '.$report->frameCount.' frames · '.$report->appFrameCount.' in your code'; $html = '

Stack trace ' - .self::e((string) $app).' of '.self::e((string) count($report->frames)).' in your code

    '; + .self::e($summary).'
      '; $opened = 0; foreach ($report->frames as $frame) { diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 8e1bb759..dbe19eb1 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -43,15 +43,20 @@ * footer's own advice about turning the trace on, which is useful on a staging box and is a free hint about * the stack to anyone else — see `hints`. * - * THE ONE ADDITION IS AUTHORED, AND IT HAS ITS OWN KEY. With `authored-detail` on (the default) a sub-500 - * failure also carries the sentence the application ITSELF wrote for a caller — a FireflyException's "Order - * 42 does not exist.", or an `abort(404, 'No such tenant.')` — because that is exactly what the problem - * document beside it publishes as `detail`, and one failure reading two ways depending on which surface - * answered is its own kind of bug. It is not an exemption from the paragraph above: ProblemMapper decides - * what counts as authored, withholds everything at 500 and above, and replaces the sentences the FRAMEWORK - * generated — the router's "The route … could not be found." and the route-model-binding 404s Laravel - * rewrites into it, which name a model class and a primary key. Turn the key off and there is no authored - * sentence to print at all. + * THE ONE ADDITION IS AUTHORED, IT HAS ITS OWN KEY, AND NOTHING PRINTS IT YET. With `authored-detail` on + * (the default) the REPORT carries, as `ErrorReport::$publicDetail`, the sentence the application ITSELF + * wrote for a caller — a FireflyException's "Order 42 does not exist.", or an `abort(404, 'No such + * tenant.')` — because that is exactly what the problem document beside it publishes as `detail`, and one + * failure reading two ways depending on which surface answered is its own kind of bug. The built-in page + * does not use it yet: its production lede is still the generic reassurance for the status, and the lede + * that will prefer this sentence is not in this tree. The value arrives first for the same reason the three + * URLs below do — a renderer written against a value that is already decided cannot be the thing that + * forgets the rule. An application's own error view reads it today, because the view is handed the report. + * None of this is an exemption from the paragraph above: ProblemMapper decides what counts as authored, + * withholds everything at 500 and above, and replaces the sentences the FRAMEWORK generated — the router's + * "The route … could not be found." and the route-model-binding 404s Laravel rewrites into it, which name a + * model class and a primary key. Turn the key off and `$publicDetail` is '', so there is no authored + * sentence for any renderer to reach for at all. */ final readonly class ErrorPageSettings { diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php index e90ff2d3..37464a8a 100644 --- a/packages/web/src/Error/ProblemMapper.php +++ b/packages/web/src/Error/ProblemMapper.php @@ -135,6 +135,14 @@ public static function authoredDetail(Throwable $e): string * base URI — which for a problem served from /api/orders/42 makes `api/orders/42` mean * /api/api/orders/42. One character, and the member stops identifying the occurrence it exists to * identify. Spring's ProblemDetail sets `instance` from the request URI for the same reason. + * + * THE PUBLISHED DOCUMENT IS NOT FIXED YET, AND THIS METHOD DOES NOT CLAIM IT IS. ProblemDetailsRenderer + * — the one surface that puts `instance` on the wire — still passes `$request->path()`, so a client + * still receives the relative form. The only caller here is ErrorReport, which hands the value to + * ErrorResponse and reads back the code, the category, the severity and the 405's verbs; the page's own + * path is built beside it. The rule arrives before its call sites deliberately, so that the conformance + * pass which changes the renderer moves one argument to an answer this package already tests, rather + * than restating what a root-relative reference is in a second place. */ public static function instanceFor(Request $request): string { diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index af5a11c6..4b7ba661 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -486,3 +486,39 @@ ->and($config(100000)->maxFrames)->toBe(500) ->and(ErrorPageSettings::fromConfig(new Config(new Repository))->maxFrames)->toBe(40); }); + +it('names the UNTRIMMED stack in the trace header, so a budgeted page cannot pass itself off as a whole one', function () { + // The budget and the header are two halves of one promise. Trimming to six frames and then counting the + // six is a label that was honest before `max-frames` existed and stops being honest the moment it does: + // it says "2 of 6 in your code" for a stack of a hundred and marks nothing where ninety-four frames went. + $trimmed = new ErrorPageSettings(trace: true, hints: false, maxFrames: 6); + $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $trimmed, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $trimmed); + + expect($error->frameCount)->toBeGreaterThan(6) + ->and($html)->toContain('6 of '.$error->frameCount.' frames · '.$error->appFrameCount.' in your code') + // One row per BUDGETED frame, still: the header names what was dropped, it does not smuggle it back. + ->and(substr_count($html, '
    1. toContain($all->frameCount.' frames · '.$all->appFrameCount.' in your code') + ->not->toContain(' of '.$all->frameCount.' frames'); +}); + +it('answers an RFC 9457 `instance` that is root-relative, including at the site root', function () { + // A relative reference resolves against the document's base URI, so `orders/42` served from /orders/42 + // identifies /orders/orders/42 — the member stops naming the occurrence it exists to name. The site + // root is the case a naive '/'.$path would get wrong in the other direction, answering '//'. + expect(ProblemMapper::instanceFor(Request::create('/orders/42')))->toBe('/orders/42') + ->and(ProblemMapper::instanceFor(Request::create('/api/v1/accounts/42')))->toBe('/api/v1/accounts/42') + ->and(ProblemMapper::instanceFor(Request::create('/')))->toBe('/') + // A query string is not part of the path, and a trailing slash is normalised away by Laravel before + // this ever sees it — both are the framework's answer, pinned here because the member depends on it. + ->and(ProblemMapper::instanceFor(Request::create('/orders/42?include=lines')))->toBe('/orders/42') + ->and(ProblemMapper::instanceFor(Request::create('/orders/')))->toBe('/orders'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 0ec52de6..c4c44cc2 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -955,14 +955,19 @@ // 'max-frames' => 40, // // /* - // | Whether the page may say the sentence the problem document already publishes — the + // | Whether the error REPORT carries the sentence the problem document already publishes — the // | application's own "Order 42 does not exist." for a sub-500 failure, which is the entire - // | point of the exception taxonomy. With it off the page falls back to the generic sentence - // | for the status, and one failure reads two ways depending on which surface answered. + // | point of the exception taxonomy — so that a page can say the same words the document does + // | instead of the generic sentence for the status. // | - // | Only sentences that were WRITTEN for a caller are ever used: below 500, from a + // | THE BUILT-IN PAGE DOES NOT PRINT IT YET. It is carried and nothing more: the production + // | page's lede is still the generic reassurance for the status. What reads the sentence today + // | is an application's own error view (`views` below), which is handed the report as `$error` + // | and can print `$error->publicDetail`. Turning this key off empties that property. + // | + // | Only sentences that were WRITTEN for a caller are ever carried: below 500, from a // | FireflyException or from an `abort(404, '…')`. At 500 and above nothing is authored — a - // | QueryException's message is the failing SQL and its bindings — and nothing is published. + // | QueryException's message is the failing SQL and its bindings — and nothing is carried. // | // | Default: true. // */ From 021ece3f1117369b72b42141ce277aae6663bd0a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 15:40:00 -0700 Subject: [PATCH 19/78] test(admin): pin the state the two listing forms carry, and correct the total label's coverage claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three partials left both GET forms unasserted. Deleting the hand-added hidden `q` from the rows-per-page form, dropping the `@continue` that lets the owns that name; this form has no such input, + so it re-adds the hidden field itself — and it @continues past `size` for the mirror reason, since two + controls with one name submit the hidden one and would leave the ') + ->toContain('') + // `q` is NOT hidden here — the search input is the control that owns it, and a hidden field of the + // same name would submit ahead of whatever the reader typed. + ->toContain('type="search" name="q"'); + + expect(Str::betweenFirst($body, '
      ', '')) + ->toContain('') + ->toContain(''); +}); + it('renders one page at a time and a pager that carries the search', function () { /** @var AdminTableCapstoneTestCase $this */ $this->get('/firefly/mappings?size=25&page=1') diff --git a/packages/admin/tests/AdminTablePagerTest.php b/packages/admin/tests/AdminTablePagerTest.php index 3c72f7f7..2c725842 100644 --- a/packages/admin/tests/AdminTablePagerTest.php +++ b/packages/admin/tests/AdminTablePagerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Admin\Tests\Support\AdminTablePagedCapstoneTestCase; +use Illuminate\Support\Str; uses(AdminTablePagedCapstoneTestCase::class); @@ -90,3 +91,51 @@ // Previous points one back from the page that was RENDERED — 10, never 9998. ->toMatch('~Previous~'); }); + +/** + * The OTHER state-carrying control in the pager, and the one that carries its state by hand. + * + * The first test in this file asserts the search survives every LINK; this one asserts it survives the + * FORM, a separate mechanism with a separate way of going wrong. `ListingQuery::hiddenFields()` omits + * `q`, because the form it was written for is the search form, whose own supplies it — so the + * rows-per-page form, which has no such input, has to re-add it itself. Delete that one line and an + * operator who narrows a listing and then asks for more rows gets all twenty-one back with no search box + * to explain it, and, before this test existed, with nothing failing either. + * + * `size` is the mirror image: hiddenFields() DOES offer it whenever it is not the default, and the ') + ->toContain('
` — 13px sans — while the cells these widths are sized for are 12.5px mono. The @@ -48,27 +60,18 @@ public static function of(TableColumn ...$columns): self */ public function widths(): array { - $characters = 0.0; - $rigid = 0; $weight = 0.0; foreach ($this->columns as $column) { - if ($column->isRigid()) { - $characters += $column->width; - $rigid++; - - continue; + if (! $column->isRigid()) { + $weight += $column->width; } - - $weight += $column->width; } - $taken = self::number($characters).'ch + '.$rigid.' * 2 * var(--row-x)'; - return array_map( fn (TableColumn $column): string => $column->isRigid() ? 'calc('.self::number($column->width).'ch + 2 * var(--row-x))' - : 'calc((100% - ('.$taken.')) * '.self::number($weight > 0.0 ? $column->width / $weight : 0.0).')', + : self::number($weight > 0.0 ? $column->width / $weight * 100 : 0.0).'%', $this->columns, ); } diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index 9b8a6d76..90519243 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -16,7 +16,13 @@ // The pill column's width carries BOTH cell paddings, because box-sizing is border-box here and a // bare 7.5ch is 7.5 characters minus 28px — which is where DELETE went. ->toContain('') - ->toContain('calc((100% - (7.5ch + 1 * 2 * var(--row-x)))'); + // The three flexible columns are bare percentages — 5/4/3 of the weight, not a `calc()` that + // subtracts the pill column. A `` width mixing a percentage with a subtracted length is not + // resolvable under `table-layout:fixed` and Chromium silently sizes the column `auto` instead, + // which is the equal-thirds layout this whole colgroup exists to replace. + ->toContain('') + ->toContain('') + ->toContain(''); }); it('types every cell of the Routes table by the vocabulary', function () { diff --git a/packages/admin/tests/Table/TableViewTest.php b/packages/admin/tests/Table/TableViewTest.php index 832efc0c..b23fd83d 100644 --- a/packages/admin/tests/Table/TableViewTest.php +++ b/packages/admin/tests/Table/TableViewTest.php @@ -40,9 +40,16 @@ expect($view->widths()[0])->toBe('calc(7.5ch + 2 * var(--row-x))'); }); -// A flexible column takes a share of what the rigid ones left, computed here rather than left to the -// browser: `table-layout:fixed` splits leftover width EVENLY between columns with no width, which is how a -// one-word Name column ends up as wide as a fully-qualified class name. +/** + * A FLEXIBLE COLUMN IS A PLAIN PERCENTAGE, AND THE ALTERNATIVE IS WHY THIS ASSERTION IS LITERAL. The + * expression this once emitted — `calc((100% - (7.5ch + 1 * 2 * var(--row-x))) * 0.4167)` — is valid CSS + * and is still not a width: a `` whose `calc()` mixes a percentage with a SUBTRACTED length is not + * resolvable by the fixed-layout algorithm, so Chromium falls back to `auto` and every flexible column + * collapses to an identical share. Measured on this exact colgroup: 453/454/452 on a 1445px table, where + * 5/4/3 asked for 567/454/340. A bare percentage needs no subtraction because the layout engine already + * subtracts — it honours the declared lengths first and splits the REMAINDER between the percentage + * columns in proportion to their percentages, which is 567/453/340 for the widths below. + */ it('shares the remaining width between the flexible columns in proportion to their weight', function () { $view = TableView::of( TableColumn::pill('httpMethod', 'Method', ch: 7.5), @@ -53,20 +60,27 @@ expect($view->widths())->toBe([ 'calc(7.5ch + 2 * var(--row-x))', - 'calc((100% - (7.5ch + 1 * 2 * var(--row-x))) * 0.4167)', - 'calc((100% - (7.5ch + 1 * 2 * var(--row-x))) * 0.3333)', - 'calc((100% - (7.5ch + 1 * 2 * var(--row-x))) * 0.25)', + '41.6667%', + '33.3333%', + '25%', ]); }); -it('subtracts every rigid column and every one of their paddings', function () { +// The rigid columns are subtracted by the LAYOUT ENGINE, not here, so adding a second one changes what the +// flexible column is measured against without changing the percentage it declares. This is the assertion +// that stops the subtraction creeping back into the expression. +it('leaves the rigid columns for the layout engine to subtract', function () { $view = TableView::of( TableColumn::pill('method', 'Method', ch: 7.5), TableColumn::number('status', 'Status', ch: 6), TableColumn::path('path', 'Path', weight: 1), ); - expect($view->widths()[2])->toBe('calc((100% - (13.5ch + 2 * 2 * var(--row-x))) * 1)'); + expect($view->widths())->toBe([ + 'calc(7.5ch + 2 * var(--row-x))', + 'calc(6ch + 2 * var(--row-x))', + '100%', + ]); }); it('gives an all-rigid table no percentage arithmetic at all', function () { @@ -78,10 +92,15 @@ it('gives an all-flexible table the whole width to share', function () { $view = TableView::of(TableColumn::text('key', 'Key', weight: 1), TableColumn::text('value', 'Value', weight: 3)); - expect($view->widths())->toBe([ - 'calc((100% - (0ch + 0 * 2 * var(--row-x))) * 0.25)', - 'calc((100% - (0ch + 0 * 2 * var(--row-x))) * 0.75)', - ]); + expect($view->widths())->toBe(['25%', '75%']); +}); + +// A weight of zero is a column that asked for nothing rather than a division by zero, and `0%` under a +// fixed layout is a column the engine gives no share of the remainder to — which is what was asked for. +it('gives a weightless flexible column no share instead of a division by zero', function () { + $view = TableView::of(TableColumn::pill('a', 'A', ch: 4), TableColumn::text('b', 'B', weight: 0)); + + expect($view->widths())->toBe(['calc(4ch + 2 * var(--row-x))', '0%']); }); it('publishes its keys and the subset that may be sorted', function () { diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php index 1e4bef1b..6d8022b9 100644 --- a/tests/Browser/AdminTablesTest.php +++ b/tests/Browser/AdminTablesTest.php @@ -51,9 +51,16 @@ ->assertNoJavaScriptErrors(); }); -// The handler column used to absorb every spare pixel through `.cls{max-width:0;width:100%}` and fling the -// Name column to the far edge. Every column now has a declared width, and none of them is the whole table. -it('gives every column a declared width and none of them the whole table', function (): void { +/** + * THE WEIGHTS, IN PIXELS, BECAUSE A BOUND PROVES NOTHING HERE. The handler column used to absorb every + * spare pixel through `.cls{max-width:0;width:100%}` and fling the Name column to the far edge; the + * colgroup declares 5/4/3 instead. This assertion is the declared proportion itself and not "no column is + * wider than 60% of the table", because that bound holds perfectly on a broken page: the first colgroup + * emitted a `calc()` mixing a percentage with a subtracted length, which is not resolvable under + * `table-layout:fixed`, so Chromium sized all three columns `auto` and drew equal thirds — 453/454/452 on + * a 1445px table, every one of them comfortably under 60%. + */ +it('splits the width the pill column leaves 5:4:3 between Path, Handler and Name', function (): void { /** @var AdminDashboardBrowserTestCase $this */ visit('/firefly/mappings') ->assertScript(<<<'JS' @@ -61,10 +68,29 @@ const table = document.querySelector('table.ftable'); const cols = [...table.querySelectorAll('col')]; if (cols.length !== 4) { return 'expected four cols, got ' + cols.length; } - const widths = [...table.querySelectorAll('thead th')].map(th => th.getBoundingClientRect().width); - return getComputedStyle(table).tableLayout === 'fixed' - && widths.every(w => w > 40) - && Math.max(...widths) < table.getBoundingClientRect().width * 0.6; + const layout = getComputedStyle(table).tableLayout; + if (layout !== 'fixed') { return 'table-layout is ' + layout; } + + const got = [...table.querySelectorAll('thead th')].map(th => th.getBoundingClientRect().width); + + // The pill column is 7.5 characters PLUS both cell paddings, and both halves are measured + // rather than assumed: `ch` is the mono advance the sheet gives `table.ftable colgroup`, + // and `--row-x` follows whatever density the deployment configured. + const ruler = document.createElement('div'); + ruler.style.cssText = 'position:absolute;visibility:hidden;font:12.5px var(--mono);width:7.5ch'; + document.body.appendChild(ruler); + const rowX = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--row-x')); + const pill = ruler.getBoundingClientRect().width + 2 * rowX; + ruler.remove(); + + // What the fixed-layout engine hands to the percentage columns is everything the declared + // length left, and 5/4/3 of that is what the Routes view asked for. + const leftover = table.getBoundingClientRect().width - got[0]; + const want = [pill, leftover * 5 / 12, leftover * 4 / 12, leftover * 3 / 12]; + + return want.every((w, i) => Math.abs(w - got[i]) <= 2) + || 'got ' + got.map(w => w.toFixed(1)).join('/') + + ', wanted ' + want.map(w => w.toFixed(1)).join('/'); })() JS, true) ->assertNoJavaScriptErrors(); @@ -100,3 +126,42 @@ ->assertNoJavaScriptErrors() ->screenshot(filename: 'admin-mappings-searched'); }); + +/** + * THE AUTO-LAYOUT PAGES STILL DEPEND ON `overflow-wrap:anywhere`, AND NOTHING USED TO SAY SO. Seven + * listings that this wave has not rebuilt yet — env, configprops, beans, health, http, metrics, the + * overview — still draw their cells as `td.mono.wrap` under `table-layout:auto`, where the column widths + * come from the CONTENT. Per CSS Text 3 only `anywhere` contributes its break opportunities to min-content + * sizing, so it is the one value that lets a token with no break of its own wrap INSIDE its column instead + * of widening the table past the panel; `break-word` looks equivalent, breaks the same token at render + * time, and sizes the column to the whole token. + * + * The scenario seeds its own worst case rather than hoping the process holds a long value: what is under + * test is min-content sizing, so the input has to be a run with no break opportunity in it, and the + * resolved `firefly.*` configuration this page happens to render is not reliably one. + */ +it('keeps a long unbreakable value inside the Environment panel', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/env') + ->assertScript(<<<'JS' + (() => { + const body = document.querySelector('#env-body'); + if (body === null) { return 'no env table on the page'; } + const wrapper = body.closest('.tw'); + const overflow = () => wrapper.scrollWidth - wrapper.clientWidth; + + const before = overflow(); + const row = document.createElement('tr'); + row.innerHTML = ''; + row.children[0].textContent = 'firefly.browser-fixture.long'; + row.children[1].textContent = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5'.repeat(3); + body.appendChild(row); + const after = overflow(); + row.remove(); + + return (before <= 1 && after <= 1) + || 'the env table overflows its panel: ' + before + 'px before the long value, ' + after + 'px after'; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); From af9d34fffa1d1291c225a42e03fecd28cb08a099 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 16:09:20 -0700 Subject: [PATCH 22/78] fix(admin): give the table wrapper a height so the sticky header finally has a scrollport --- .../admin/resources/views/layout.blade.php | 37 ++++++++++++++++++- .../tests/AdminTableScrollportOffTest.php | 13 +++++++ .../tests/AdminTableScrollportRefusedTest.php | 18 +++++++++ .../admin/tests/AdminTableScrollportTest.php | 23 ++++++++++++ .../AdminTableScrollportOffTestCase.php | 28 ++++++++++++++ .../AdminTableScrollportRefusedTestCase.php | 33 +++++++++++++++++ tests/Browser/AdminTablesTest.php | 33 +++++++++++++++++ 7 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/admin/tests/AdminTableScrollportOffTest.php create mode 100644 packages/admin/tests/AdminTableScrollportRefusedTest.php create mode 100644 packages/admin/tests/AdminTableScrollportTest.php create mode 100644 packages/admin/tests/Support/AdminTableScrollportOffTestCase.php create mode 100644 packages/admin/tests/Support/AdminTableScrollportRefusedTestCase.php diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 42940a64..e8d7fbb1 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -261,7 +261,21 @@ .stat a{color:inherit} /* ── tables ──────────────────────────────────────────────────────── */ - .tw{overflow:auto} + /* + STICKY HAS NEVER WORKED HERE, AND THIS LINE IS WHY. `thead th` has carried `position:sticky;top:0` + for as long as the sheet has existed, and `.tw{overflow-x:auto}` computes `overflow-y` to `auto` + as well — which makes `.tw` the nearest scrollport for those headers. But `.tw` had NO height + constraint anywhere in the sheet, so it never scrolled: `main` did. A sticky element does not + follow an ancestor's scrollport, so the header stayed pinned to a box that was moving, and a + reviewer measured the `, where the scrollport pins nothing and costs a second scrollbar plus a footer link pushed out of sight — and that is the overview's four summary panels, which now carry it. The stylesheet comment's old justification was wrong twice over: `max-height` reserves no space, so a four-row health table was never "mostly empty box", and health draws a header and keeps its scrollport. The assertion is now a computed `max-height` read off the elements themselves. The scroll restore was ungated, untested and wider than its own comment: it ran on every load of the URL, so clicking Beans in the sidebar an hour later dropped a reader into the middle of a table with the document at the top and nothing to explain it. It is now applied only on `reload` and `back_forward` — exactly the navigations where the browser restored the offset itself before `.tw` took the scrollport from `main` — and switched by `firefly.admin.table.remember-scroll`, documented in the skeleton reference and defaulting to on, because it restores parity rather than inventing an affordance. Saving moved from `beforeunload` to `pagehide`, which does not make the page ineligible for the back/forward cache. Both halves are measured in Chromium: the reload brings the offset back, the plain navigation opens at the top. --- .../admin/resources/views/layout.blade.php | 53 +++++++++--- .../admin/resources/views/overview.blade.php | 8 +- packages/admin/src/Table/TableSettings.php | 10 +++ .../tests/AdminTableScrollMemoryOffTest.php | 17 ++++ .../admin/tests/AdminTableScrollportTest.php | 47 +++++++++-- .../AdminTableScrollMemoryOffTestCase.php | 29 +++++++ .../admin/tests/Table/TableSettingsTest.php | 11 ++- skeleton/config/firefly.php | 15 ++++ tests/Browser/AdminTablesTest.php | 82 +++++++++++++++++++ 9 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 packages/admin/tests/AdminTableScrollMemoryOffTest.php create mode 100644 packages/admin/tests/Support/AdminTableScrollMemoryOffTestCase.php diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index e8d7fbb1..fa95da29 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -272,7 +272,17 @@ whole-document scroll back, header and all. */ .tw{overflow:auto;max-height:var(--table-vh)} - /* Panels whose rows are a fixed handful and must never grow an inner scrollbar. */ + /* + THE OPT-OUT, AND EXACTLY WHO TAKES IT: a wrapper around a table with NO ``. The scrollport + above exists to give `position:sticky` something to stick to, so a headerless summary panel gains + nothing from it and pays twice — a second scrollbar inside a page that already has one, and a + footer link ("All traffic →") pushed under a box the reader has to scroll past to reach it. The + four panels on the overview are the whole of that set and they carry `class="tw free"`; every + other `.tw` on the dashboard wraps a table that draws a header and wants the scrollport. + Note what this class is NOT for: a table with few rows. `max-height` reserves no space, so a + four-row health table is four rows tall either way — the rule only ever bites once the rows + overflow, and by then a pinned header is what the reader wants. + */ .tw.free{max-height:none} /* A nested scroller should stop at its own end rather than handing the gesture to the page mid-table. */ .tw{overscroll-behavior:contain} @@ -709,26 +719,47 @@ function start() { }); }); - // The auto-refresh reloads the whole page, which is the right thing for a server-rendered dashboard - // — the URL carries the page, the sort and the search, so a reader on page 7 comes back to page 7. - // The one thing a reload does not restore is the scroll position INSIDE a table, because the - // scrollport is now the wrapper rather than the document. Keyed by the full URL so page 7's - // position is not applied to page 8. + // KEEPING THE READING POSITION WHERE THE BROWSER USED TO KEEP IT ITSELF. The auto-refresh reloads the + // whole page, which is the right thing for a server-rendered dashboard — the URL carries the page, + // the sort and the search, so a reader on page 7 comes back to page 7. Until this wave the reading + // position came back too, for free: `main` was the scrollport and a browser restores the DOCUMENT's + // offset across a reload. Giving `.tw` a height moved the scrollport inside the panel, and a browser + // does not restore an inner scroller — so the refresh started dropping the reader back to row 1. + // + // RESTORED ONLY WHERE THE BROWSER WOULD HAVE RESTORED IT — `reload` and `back_forward`, read off the + // Navigation Timing entry. A plain `navigate`, which is what clicking Beans in the sidebar an hour + // later is, leaves the saved offset alone and opens the table at the top: arriving at a page that is + // already scrolled into the middle of a table, with the document itself at the top and nothing on + // screen to explain it, is a defect rather than a convenience. An engine with no navigation entry + // gets the same treatment, because not restoring is the answer that can only disappoint. + // + // Keyed by the full URL, so page 7's position is never applied to page 8. Saved on `pagehide` rather + // than `beforeunload`: an unconditional `beforeunload` listener makes the page ineligible for the + // back/forward cache, which is one of the two navigations this exists to serve. + // + // `firefly.admin.table.remember-scroll` is the way out; the block is not emitted when it is off. + @if ($settings->table->rememberScroll) (function () { var key = 'firefly-admin-scroll:' + window.location.pathname + window.location.search; var wrappers = document.querySelectorAll('.tw'); + var entry = window.performance && performance.getEntriesByType + ? performance.getEntriesByType('navigation')[0] + : null; - try { - var saved = JSON.parse(sessionStorage.getItem(key) || '[]'); - wrappers.forEach(function (wrapper, index) { wrapper.scrollTop = saved[index] || 0; }); - } catch (e) { /* private mode, or a stale shape */ } + if (entry && (entry.type === 'reload' || entry.type === 'back_forward')) { + try { + var saved = JSON.parse(sessionStorage.getItem(key) || '[]'); + wrappers.forEach(function (wrapper, index) { wrapper.scrollTop = saved[index] || 0; }); + } catch (e) { /* private mode, or a stale shape */ } + } - window.addEventListener('beforeunload', function () { + window.addEventListener('pagehide', function () { try { sessionStorage.setItem(key, JSON.stringify(Array.prototype.map.call(wrappers, function (w) { return w.scrollTop; }))); } catch (e) { /* private mode, or the quota */ } }); })(); + @endif // Bean graph: hovering or clicking a node lights its edges and both endpoints, and dims everything // else. Reading a dependency diagram is asking "what touches THIS", and a static picture cannot diff --git a/packages/admin/resources/views/overview.blade.php b/packages/admin/resources/views/overview.blade.php index f87f7b26..2c7c2cf3 100644 --- a/packages/admin/resources/views/overview.blade.php +++ b/packages/admin/resources/views/overview.blade.php @@ -44,7 +44,7 @@ 'body' => 'Implement Firefly\Actuator\Health\HealthIndicator and register it as a bean to see it here.', ]) @else -
+
at `top: -360px` after scrolling. Giving the wrapper a height is the + entire fix, and `--table-vh` is how a deployment sizes it — or sets `none` and gets the old + whole-document scroll back, header and all. + */ + .tw{overflow:auto;max-height:var(--table-vh)} + /* Panels whose rows are a fixed handful and must never grow an inner scrollbar. */ + .tw.free{max-height:none} + /* A nested scroller should stop at its own end rather than handing the gesture to the page mid-table. */ + .tw{overscroll-behavior:contain} table{border-collapse:collapse;width:100%;font-size:13px} thead th{ position:sticky;top:0;z-index:1;text-align:left;padding:var(--row-y) var(--row-x);background:var(--panel-2); @@ -695,6 +709,27 @@ function start() { }); }); + // The auto-refresh reloads the whole page, which is the right thing for a server-rendered dashboard + // — the URL carries the page, the sort and the search, so a reader on page 7 comes back to page 7. + // The one thing a reload does not restore is the scroll position INSIDE a table, because the + // scrollport is now the wrapper rather than the document. Keyed by the full URL so page 7's + // position is not applied to page 8. + (function () { + var key = 'firefly-admin-scroll:' + window.location.pathname + window.location.search; + var wrappers = document.querySelectorAll('.tw'); + + try { + var saved = JSON.parse(sessionStorage.getItem(key) || '[]'); + wrappers.forEach(function (wrapper, index) { wrapper.scrollTop = saved[index] || 0; }); + } catch (e) { /* private mode, or a stale shape */ } + + window.addEventListener('beforeunload', function () { + try { + sessionStorage.setItem(key, JSON.stringify(Array.prototype.map.call(wrappers, function (w) { return w.scrollTop; }))); + } catch (e) { /* private mode, or the quota */ } + }); + })(); + // Bean graph: hovering or clicking a node lights its edges and both endpoints, and dims everything // else. Reading a dependency diagram is asking "what touches THIS", and a static picture cannot // answer that once there is more than a handful of nodes. diff --git a/packages/admin/tests/AdminTableScrollportOffTest.php b/packages/admin/tests/AdminTableScrollportOffTest.php new file mode 100644 index 00000000..3a367f6c --- /dev/null +++ b/packages/admin/tests/AdminTableScrollportOffTest.php @@ -0,0 +1,13 @@ +get('/firefly/mappings')->assertStatus(200)->getContent()) + ->toContain('--table-vh:none'); +}); diff --git a/packages/admin/tests/AdminTableScrollportRefusedTest.php b/packages/admin/tests/AdminTableScrollportRefusedTest.php new file mode 100644 index 00000000..039d43dc --- /dev/null +++ b/packages/admin/tests/AdminTableScrollportRefusedTest.php @@ -0,0 +1,18 @@ + element. There is no escaping that makes an arbitrary string +// safe there, so it is refused at the settings boundary and this is the end-to-end proof. +it('refuses a max-height that is not a length, rather than writing it into the stylesheet', function () { + /** @var AdminTableScrollportRefusedTestCase $this */ + $body = (string) $this->get('/firefly/mappings')->assertStatus(200)->getContent(); + + expect($body)->toContain('--table-vh:68vh') + ->not->toContain('body{display:none') + ->not->toContain(AdminTableScrollportRefusedTestCase::HOSTILE_HEIGHT); +}); diff --git a/packages/admin/tests/AdminTableScrollportTest.php b/packages/admin/tests/AdminTableScrollportTest.php new file mode 100644 index 00000000..47f3b457 --- /dev/null +++ b/packages/admin/tests/AdminTableScrollportTest.php @@ -0,0 +1,23 @@ +get('/firefly/mappings')->assertStatus(200)->getContent(); + + expect($body)->toContain('--table-vh:68vh') + ->toContain('.tw{overflow:auto;max-height:var(--table-vh)}'); +}); + +// The opt-out, because a scrollport is wrong for a panel whose rows are a fixed handful: a four-row health +// table inside a box with 68vh of height reserved for it is mostly empty box. +it('offers a panel the way out of the scrollport it does not want', function () { + /** @var AdminCapstoneTestCase $this */ + expect((string) $this->get('/firefly/mappings')->getContent()) + ->toContain('.tw.free{max-height:none}'); +}); diff --git a/packages/admin/tests/Support/AdminTableScrollportOffTestCase.php b/packages/admin/tests/Support/AdminTableScrollportOffTestCase.php new file mode 100644 index 00000000..8747ee81 --- /dev/null +++ b/packages/admin/tests/Support/AdminTableScrollportOffTestCase.php @@ -0,0 +1,28 @@ +set()` inside the test, for the + * reason AdminTablePagedCapstoneTestCase already documents: AdminSettings — and the TableSettings hanging + * off it — is built once by AdminRouteRegistrar during the boot passes and bound as an instance, so a set + * from a test body arrives after the object that would have read it and the page still renders the default. + */ +abstract class AdminTableScrollportOffTestCase extends AdminCapstoneTestCase +{ + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.table.max-height' => 'none', + ]; + } +} diff --git a/packages/admin/tests/Support/AdminTableScrollportRefusedTestCase.php b/packages/admin/tests/Support/AdminTableScrollportRefusedTestCase.php new file mode 100644 index 00000000..20e27ade --- /dev/null +++ b/packages/admin/tests/Support/AdminTableScrollportRefusedTestCase.php @@ -0,0 +1,33 @@ +` ELEMENT, which is the one place on this page where Blade's + * escaping buys nothing: by the time an entity would be decoded the CSS parser has already left the + * declaration, so `1px}body{display:none` closes the rule and opens its own. TableSettings::height() + * therefore refuses anything that is not a length or `none` and falls back to the default, and this case + * exists to prove that end to end — through a real boot, a real request and the real sheet — rather than + * only against the value object. + * + * Seeded through configOverrides() because the settings are built during the boot passes; see + * AdminTableScrollportOffTestCase. + */ +abstract class AdminTableScrollportRefusedTestCase extends AdminCapstoneTestCase +{ + /** The payload: a length, the closing brace of the rule it lands in, and a rule of the attacker's own. */ + public const string HOSTILE_HEIGHT = '1px}body{display:none'; + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.table.max-height' => self::HOSTILE_HEIGHT, + ]; + } +} diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php index 6d8022b9..64ddd36a 100644 --- a/tests/Browser/AdminTablesTest.php +++ b/tests/Browser/AdminTablesTest.php @@ -165,3 +165,36 @@ JS, true) ->assertNoJavaScriptErrors(); }); + +/** + * THE ONLY ASSERTION THAT WILL KEEP THIS FIXED. The header has always CARRIED `position:sticky`, so a + * markup assertion proves nothing — it proved nothing for as long as the defect existed. This scrolls the + * wrapper and measures the against it, and it asserts the wrapper actually moved: before the fix + * `scrollTop` stays 0 because the box has no height to overflow, and a test that only compared the two + * rectangles would have passed on the broken sheet. + */ +it('pins the column headers to the top of the table while its rows scroll', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/beans?size=200') + ->assertScript(<<<'JS' + (() => { + const wrapper = document.querySelector('.tw'); + const header = document.querySelector('thead th'); + wrapper.scrollTop = 600; + if (wrapper.scrollTop < 100) { return 'the wrapper did not scroll: ' + wrapper.scrollTop; } + const offset = header.getBoundingClientRect().top - wrapper.getBoundingClientRect().top; + return Math.abs(offset) < 2 ? true : 'header drifted to ' + offset; + })() + JS, true) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'admin-beans-sticky'); +}); + +// Under border-collapse the sticky cell's border belongs to the table's border grid and stays behind with +// the rows, so the header scrolls out from under its own underline. An inset shadow travels with the cell. +it('keeps the rule under the pinned header', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/beans') + ->assertScript("getComputedStyle(document.querySelector('thead th')).boxShadow.includes('inset')", true) + ->assertNoJavaScriptErrors(); +}); From 61985fa1fd2ee9f7325f80b7a9f5b5224c7ff80f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 16:12:12 -0700 Subject: [PATCH 23/78] fix(web): keep a frame's method name out of the ellipsis and raise the trace's focus rings to 3:1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trace row printed the whole call in one shrinkable span, and a call is `Class->method()`: the ellipsis took the METHOD NAME — the only token that differs across the sixty `Illuminate\…` frames of a Laravel stack — and kept the namespace every one of them shares. The call is now split the way the path already was: ErrorFrame gains callQualifier() and callFunction(), cut at the last `->`, `::` or `\` before any `{` so PHP 8.4's `{closure:/abs/path.php:14}` descriptor survives whole, and the row emits a shrinkable `.cls` beside a `.fn` that is flex:none. A phone drops the qualifier outright rather than shortening the method name, because `Builder.php` two columns to its left already said it. row()'s docblock claimed only `.dir` and `.call` had a discardable end, which was false of both; it now names the end the ellipsis takes and what that costs. The two focus rings this trace added were drawn in --brand, which measures 2.86:1 on --panel-2 — below SC 1.4.11's 3:1 floor for a non-text indicator, on the dependency disclosure, the one control a keyboard reader must operate to reach the frames behind it. Both now use --brand-ink, the token this file already keeps for the brand as a foreground: 5.35:1 and 5.63:1 in light, unchanged in dark. The contrast test measured --ink-3 on three grounds and skipped the indicator it shipped in the same commit; it now measures both, at each one's own threshold, and pins the token as a string. The browser suite's `assertSee('app/Orders/OrderService.php')` asserted a string the document stopped containing when the path became two spans; it only matched because Playwright concatenates sibling text with no separator. It names both halves instead. --- packages/web/src/Error/ErrorFrame.php | 58 +++++++++++++++++++++ packages/web/src/Error/ErrorPage.php | 52 +++++++++++++++--- packages/web/tests/Error/ErrorFrameTest.php | 39 +++++++++++++- packages/web/tests/Error/ErrorPageTest.php | 42 ++++++++++++++- tests/Browser/ErrorPagesDebugTest.php | 8 ++- 5 files changed, 187 insertions(+), 12 deletions(-) diff --git a/packages/web/src/Error/ErrorFrame.php b/packages/web/src/Error/ErrorFrame.php index b74092a9..794340c0 100644 --- a/packages/web/src/Error/ErrorFrame.php +++ b/packages/web/src/Error/ErrorFrame.php @@ -8,6 +8,10 @@ * One stack frame, with the two things that make a trace readable: whether it is YOURS, and what the code * around it says. * + * Both of its halves — the path and the call — are offered SPLIT, because the page prints each on one line + * and a line runs out of width. `dir()`/`base()` and `callQualifier()`/`callFunction()` are the same idea + * twice: a qualifier that may be ellipsised, and the token that identifies the frame and never may. + * * A raw PHP trace is forty frames of which perhaps four are the application's, and the rest are the * framework walking its own dispatch. Marking the application's frames is what turns scrolling into * reading, and it is decided by path — a frame under `vendor/` belongs to a dependency — which is crude, @@ -54,6 +58,60 @@ public function base(): string return $at < 0 ? $this->shortFile : substr($this->shortFile, $at + 1); } + /** + * The part of $call BEFORE its last qualifier — the class or the namespace — without the separator; + * '' when the call carries none (`throw`, `array_map()`, `{closure}`). + * + * This is the half of a call a row is allowed to clip, and it is the half a path clips too: for + * `Illuminate\Database\Eloquent\Builder->get()` it is everything the neighbouring `Builder.php` already + * says, printed once more only because a reader scanning for a namespace wants to see it. + */ + public function callQualifier(): string + { + $at = $this->callSplit(); + + return $at < 0 ? '' : substr($this->call, 0, $at); + } + + /** + * The function half of $call, WITH the separator that introduces it (`->get()`, `::make()`, `\collect()`). + * + * NEVER shortened, for the same reason base() is not. A Laravel trace is sixty `Illuminate\…` frames + * whose qualifiers differ by a segment or two and whose METHOD NAMES are the only tokens that tell them + * apart; a row that clipped its way to `Illuminate\Database\Eloq…` would have printed sixty identical + * lines. + */ + public function callFunction(): string + { + $at = $this->callSplit(); + + return $at < 0 ? $this->call : substr($this->call, $at); + } + + /** + * The offset of the last `->`, `::` or `\` in $call, or -1 when there is none. + * + * Searched only BEFORE the first `{`, because a closure names itself inside braces and PHP 8.4 puts a + * file path in there — `App\Jobs\Sync::{closure:C:\app\Jobs\Sync.php:31}()` carries three separators + * that belong to a Windows path, and cutting at one of those would split the descriptor in half. + */ + private function callSplit(): int + { + $brace = strpos($this->call, '{'); + $scan = $brace === false ? $this->call : substr($this->call, 0, $brace); + + $at = -1; + foreach (['->', '::', '\\'] as $marker) { + $found = strrpos($scan, $marker); + + if ($found !== false && $found > $at) { + $at = $found; + } + } + + return $at; + } + /** * The Composer package this frame belongs to (`laravel/framework`), or null for application code. * diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index 5011666e..3f996024 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -213,19 +213,36 @@ private static function frame(ErrorFrame $frame, bool $open): string * A frame on ONE LINE, whatever the width. * * The order is the order a reader scans: where in the stack, whose code, which directory, WHICH FILE, - * which line, what was called. Only `.dir` and `.call` may be clipped, because only they have a - * discardable end; the file name and the line number are the answer and are never shortened. + * which line, what was called. + * + * WHAT THE ELLIPSIS TAKES, AND FROM WHICH END. `text-overflow:ellipsis` always drops the END of a span, + * so the only honest way to protect a token is to give it a span of its own that cannot shrink. Three + * tokens have one: the file name, the line, and the FUNCTION — `->get()` — because those are what tell + * one frame from another. A Laravel trace is sixty `Illuminate\…` frames whose method names are the + * only difference between them, and printing the call as a single span clipped exactly that away: + * sixty rows reading `Illuminate\Database\Eloq…`. So the call is split the way the path already was, + * into a shrinkable `.cls` and a `.fn` that is `flex:none`. + * + * That leaves `.dir` and `.cls` as the two spans the row is willing to lose the tail of, and the loss + * is not free: `.cls` loses a class name the neighbouring file name repeats, which costs nothing, but + * `.dir` loses its innermost directories, which is real. It is ranked last anyway — the file name, the + * line and the package badge beside it are enough to find the file, and a row has to give up something + * before it wraps. */ private static function row(ErrorFrame $frame): string { $package = $frame->package(); + $qualifier = $frame->callQualifier(); return ''.self::e('#'.$frame->index).'' .($package === null ? '' : ''.self::e($package).'') .''.self::e($frame->dir()).'' .''.self::e($frame->base()).'' .''.($frame->line === null ? '' : self::e(':'.$frame->line)).'' - .''.self::e($frame->call).''; + .'' + .($qualifier === '' ? '' : ''.self::e($qualifier).'') + .''.self::e($frame->callFunction()).'' + .''; } /** @@ -378,19 +395,33 @@ private static function css(): string .frames li:last-child{border-bottom:0} /* ONE LINE PER FRAME. The old rule was flex-wrap:wrap with overflow-wrap:anywhere on the path and margin-left:auto on the call, so every row became a wrapped path plus a third line for the call — an - 87px pitch that made a hundred-frame trace 10,108 pixels tall. Only .dir and .call may shrink. */ + 87px pitch that made a hundred-frame trace 10,108 pixels tall. Only .dir and .cls may shrink. */ .frames .row,.frames summary{display:flex;gap:8px;align-items:baseline;padding:7px 16px;min-width:0;flex-wrap:nowrap;white-space:nowrap} .frames summary{cursor:pointer;list-style:none} .frames summary::-webkit-details-marker{display:none} .frames summary::before{content:"▸";color:var(--ink-3);font-size:10px;flex:none} .frames details[open] summary::before{content:"▾"} -.frames summary:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +/* THE RING IS DRAWN IN --brand-ink, NOT --brand. A focus indicator is a non-text contrast target: WCAG + 2.1 SC 1.4.11 asks for 3:1 against what it sits on, and #e07a17 measures 3.01:1 on a white panel and + 2.86:1 on --panel-2, which is exactly where the dependency disclosure's ring is drawn. Passing by 0.01 + on one ground and failing on the other is not a contrast decision, it is an accident. --brand-ink is + the token this file already keeps for the brand as a foreground: 5.63:1 and 5.35:1 on those two grounds + in light, and identical to --brand in dark, where both are #ff9d3c. */ +.frames summary:focus-visible{outline:2px solid var(--brand-ink);outline-offset:-2px} .frames .ix{font-family:var(--mono);font-size:11px;color:var(--ink-3);flex:none;min-width:2.8em;text-align:right} .frames .pkg{font-size:11px;line-height:1.7;color:var(--ink-2);background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:0 5px;flex:none;max-width:13em;overflow:hidden;text-overflow:ellipsis} .frames .dir{font-family:var(--mono);font-size:12.5px;color:var(--ink-2);min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis} .frames .base{font-family:var(--mono);font-size:12.5px;color:var(--ink);flex:none} .frames .ln{font-family:var(--mono);font-size:12.5px;color:var(--ink-2);flex:none} -.frames .call{font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-left:auto;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis} +/* The call is the path's twin: .cls is the qualifier and may be ellipsised, .fn is the method name and is + the token that tells sixty Illuminate frames apart, so it never shrinks. A nested flex rather than two + row items, so the pair stays glued (the row's 8px gap would print `Collection ->each()`). */ +.frames .call{font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-left:auto;min-width:0;flex:0 1 auto;display:flex;align-items:baseline} +.frames .call .cls{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis} +/* flex:none, with one guard: PHP 8.4 names a closure `{closure:/abs/path/file.php:14}`, so a "function + name" can be a hundred characters of absolute path. The cap sits far above any real method name and + bites only that case, which would otherwise push the row out past the panel. */ +.frames .call .fn{flex:none;max-width:24em;overflow:hidden;text-overflow:ellipsis} /* The application's own frames are the point of the page; the dependency ones are context. */ .frames li.own{border-left:3px solid var(--brand);background:var(--panel)} .frames li.own .base{font-weight:600} @@ -402,7 +433,8 @@ private static function css(): string .deps>summary::-webkit-details-marker{display:none} .deps>summary::before{content:"▸";color:var(--ink-3);font-size:10px} .deps[open]>summary::before{content:"▾"} -.deps>summary:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +/* The one control a keyboard reader MUST operate to reach the dependency frames, on --panel-2. */ +.deps>summary:focus-visible{outline:2px solid var(--brand-ink);outline-offset:-2px} .deps .dn{margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--ink-3)} .deps-list{border-top:1px solid var(--line)} .src{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12.5px;background:var(--panel-2);border-top:1px solid var(--line);display:block;overflow-x:auto} @@ -418,7 +450,11 @@ private static function css(): string .sheet{padding:32px 14px 48px} .status b{font-size:48px} .frames .pkg{display:none} - .frames .call{max-width:38%} + /* The qualifier goes entirely, before the method name loses a character: on a phone row + `Illuminate\Database\Eloquent\Builder` is what `Builder.php` two columns to its left already said, + and `->get()` is not said anywhere else. */ + .frames .call .cls{display:none} + .frames .call .fn{max-width:14em} .frames .ix{min-width:2.2em} } CSS; diff --git a/packages/web/tests/Error/ErrorFrameTest.php b/packages/web/tests/Error/ErrorFrameTest.php index 44c04d06..f453b073 100644 --- a/packages/web/tests/Error/ErrorFrameTest.php +++ b/packages/web/tests/Error/ErrorFrameTest.php @@ -5,12 +5,14 @@ use Firefly\Web\Error\ErrorFrame; /** - * A frame knows three things about its own path: where it is, what it is called, and whose code it is. + * A frame knows three things about its own path: where it is, what it is called, and whose code it is — + * and the same two things about its call. * * They are separate accessors rather than one formatted string because the page needs them separately: the * DIRECTORY may be ellipsised when a row runs out of width, the FILE NAME never may — a row that ends in * `…Contro…` has stopped being information — and the PACKAGE is what turns ninety dim rows into "everything - * here is laravel/framework". + * here is laravel/framework". The call splits on the same rule and for the same reason: the QUALIFIER may + * be clipped, the METHOD NAME may not, because it is what tells sixty `Illuminate\…` frames apart. */ it('splits a shortened path into a directory and a file name', function () { $frame = new ErrorFrame( @@ -76,3 +78,36 @@ expect($frame->base())->toBe('Route.php') ->and($frame->dir())->toBe('vendor\\laravel\\framework\\src\\'); }); + +it('splits a call into a qualifier that may be clipped and a function name that never is', function () { + $frame = static fn (string $call): ErrorFrame => new ErrorFrame( + file: '/srv/app/x.php', shortFile: 'x.php', line: 1, call: $call, vendor: false, + ); + + expect($frame('Illuminate\Database\Eloquent\Builder->get()')->callQualifier())->toBe('Illuminate\Database\Eloquent\Builder') + ->and($frame('Illuminate\Database\Eloquent\Builder->get()')->callFunction())->toBe('->get()') + ->and($frame('Illuminate\Routing\Route::make()')->callQualifier())->toBe('Illuminate\Routing\Route') + ->and($frame('Illuminate\Routing\Route::make()')->callFunction())->toBe('::make()') + // A namespaced free function has no class, and its namespace is still the discardable half. + ->and($frame('Illuminate\Support\collect()')->callQualifier())->toBe('Illuminate\Support') + ->and($frame('Illuminate\Support\collect()')->callFunction())->toBe('\collect()'); +}); + +it('leaves a call with no qualifier whole, and reads a closure descriptor as part of the function', function () { + $frame = static fn (string $call): ErrorFrame => new ErrorFrame( + file: '/srv/app/x.php', shortFile: 'x.php', line: 1, call: $call, vendor: false, + ); + + // The synthetic first frame, an internal function, and a bare closure: nothing to clip, so the whole + // token is the function and the qualifier slot is not printed at all. + expect($frame('throw')->callQualifier())->toBe('') + ->and($frame('throw')->callFunction())->toBe('throw') + ->and($frame('array_map()')->callQualifier())->toBe('') + ->and($frame('array_map()')->callFunction())->toBe('array_map()') + ->and($frame('{closure}')->callQualifier())->toBe('') + ->and($frame('{closure}')->callFunction())->toBe('{closure}') + // PHP 8.4 names a closure after the file it was written in, and on Windows that path carries + // separators of its own. The cut is looked for before the first brace so the descriptor survives. + ->and($frame('App\Jobs\Sync::{closure:C:\app\Jobs\Sync.php:31}()')->callQualifier())->toBe('App\Jobs\Sync') + ->and($frame('App\Jobs\Sync::{closure:C:\app\Jobs\Sync.php:31}()')->callFunction())->toBe('::{closure:C:\app\Jobs\Sync.php:31}()'); +}); diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 751ffb90..b8f397da 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -601,7 +601,30 @@ expect($html)->toMatch('#[a-z0-9._-]+/[a-z0-9._-]+#'); }); -it('keeps every text token above 4.5:1 on every ground it is printed on', function () { +it('gives the method name a span of its own, so the token a vendor frame is known by is never the clipped end', function () { + $settings = new ErrorPageSettings(trace: true, hints: false, maxFrames: 60); + // A Pest run reaches this closure through its own vendor code, so the trace really is a deep vendor + // stack — the shape the row was designed for and the one that exposed the bug. + $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + $vendor = count(array_filter($error->frames, static fn ($f): bool => $f->vendor)); + + expect($vendor)->toBeGreaterThan(5) + // `.call` used to be one ellipsised span, and a call is `Class->method()`: the clip took the METHOD + // NAME and kept the namespace every Illuminate frame shares. The pair is now the path's pair. + ->and($html)->toMatch('#[^<]+(->|::)[A-Za-z_]#') + ->toContain('.frames .call .fn{flex:none;') + ->toContain('.frames .call .cls{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}') + // A phone drops the qualifier whole rather than shortening the method name. + ->toContain('.frames .call .cls{display:none}') + // The old single span, with the whole call inside the shrinkable box, must not come back. + ->not->toContain('.frames .call{font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-left:auto;min-width:0;flex:0 1 auto;overflow:hidden') + // The synthetic throw frame has no qualifier at all, so the slot is simply not printed. + ->and($html)->toContain('throw'); +}); + +it('keeps every text token above 4.5:1, and the focus ring above 3:1, on every ground each is drawn on', function () { // --ink-3 was #8d95a1 in light (2.80:1 on the page ground, 3.02:1 on a panel) and #6c7883 in dark // (3.76:1 on the inset panel). It carries the call, the frame index and the panel counts — small text a // reader is asked to compare, which is the last place to spend contrast. @@ -635,4 +658,21 @@ ->and($ratio('#828e99', '#0f1214'))->toBeGreaterThan(4.5) ->and($ratio('#828e99', '#15191c'))->toBeGreaterThan(4.5) ->and($ratio('#828e99', '#181d21'))->toBeGreaterThan(4.5); + + // THE FOCUS RING, which is not text and so is measured against SC 1.4.11's 3:1, not 4.5:1. It is drawn + // on two grounds: a frame's summary on --panel, and the dependency disclosure — the one control a + // keyboard reader MUST operate to reach the frames behind it — on --panel-2. In --brand those measured + // 3.01:1 and 2.86:1, so the ring on the control that matters most was the one below the floor. + expect($html)->toContain('--brand-ink:#a1520a') + ->toContain('.frames summary:focus-visible{outline:2px solid var(--brand-ink)') + ->toContain('.deps>summary:focus-visible{outline:2px solid var(--brand-ink)') + // Pinned as a string so the token cannot silently go back to the shape one that fails. + ->not->toContain('outline:2px solid var(--brand)') + ->and($ratio('#a1520a', '#ffffff'))->toBeGreaterThan(3.0) + ->and($ratio('#a1520a', '#faf9f6'))->toBeGreaterThan(3.0) + // Dark leaves the ring on --brand's own value; both tokens are #ff9d3c there. + ->and($ratio('#ff9d3c', '#15191c'))->toBeGreaterThan(3.0) + ->and($ratio('#ff9d3c', '#181d21'))->toBeGreaterThan(3.0) + // And the value that was failing is recorded as failing, so the swap cannot be undone by accident. + ->and($ratio('#e07a17', '#faf9f6'))->toBeLessThan(3.0); }); diff --git a/tests/Browser/ErrorPagesDebugTest.php b/tests/Browser/ErrorPagesDebugTest.php index ff21cf72..4010bc6a 100644 --- a/tests/Browser/ErrorPagesDebugTest.php +++ b/tests/Browser/ErrorPagesDebugTest.php @@ -29,7 +29,13 @@ ->assertSee('ORDER_NOT_FOUND') ->assertSee('That order does not exist.') ->assertSee('ResourceNotFoundException') - ->assertSee('app/Orders/OrderService.php') + // WAVE UX-E: a frame row prints its path as TWO spans — a directory that may be ellipsised when + // the row runs out of width, and a file name that never may. `assertSee('app/Orders/OrderService.php')` + // only ever matched because Playwright concatenates sibling text with no separator; it asserted a + // string the document no longer contains anywhere. Both halves are named instead, so the assertion + // says what the page actually promises and fails loudly if either span is dropped. + ->assertSee('OrderService.php') + ->assertSourceHas('app/Orders/OrderService.php') ->assertNoJavaScriptErrors() ->screenshot(filename: 'error-404-domain-debug'); }); From 06e06b293cf42803e6532a70027e48ae6f27c6f9 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 16:25:59 -0700 Subject: [PATCH 24/78] fix(admin): put the table scrollport opt-out on the panels that take it, and gate the scroll restore behind a documented key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.tw.free` shipped selecting nothing: no view in the package put `free` on a wrapper, and the test beside it looked for the rule's text in the stylesheet, which a rule that can never match still satisfies. The set that wants it is precise — a wrapper around a table with no `
@foreach ($indicators as $indicator) @@ -77,7 +77,7 @@ 'body' => 'No InfoContributor has contributed anything. Set firefly.management.info.app, or register your own contributor.', ]) @else -
+
@foreach ($info as $key => $value) @@ -97,7 +97,7 @@ @if ($exchanges !== [])
@include('firefly-admin::_panel-head', ['title' => 'Recent requests', 'count' => count($exchanges)]) -
+
@foreach ($exchanges as $exchange) @@ -122,7 +122,7 @@ @if ($metrics !== [])
@include('firefly-admin::_panel-head', ['title' => 'Metrics', 'count' => count($metrics)]) -
+
@foreach (array_slice($metrics, 0, 8) as $metric) diff --git a/packages/admin/src/Table/TableSettings.php b/packages/admin/src/Table/TableSettings.php index 031f7434..a8926af7 100644 --- a/packages/admin/src/Table/TableSettings.php +++ b/packages/admin/src/Table/TableSettings.php @@ -23,6 +23,14 @@ * declaration by the time any entity would be decoded. `none` is admitted on purpose: it is how a deployment * turns the scrollport, and with it the sticky header, off. * + * `remember-scroll` is the other half of that move. Giving `.tw` a height took the scrollport away from + * `main`, and a browser restores the DOCUMENT's scroll offset across a reload but not an inner scroller's — + * so the ten-second auto-refresh, which had cost a reader nothing, started dropping them back to row 1 of + * the page they were reading. The dashboard therefore saves each wrapper's offset per URL and puts it back, + * and this key is how a deployment declines. It defaults to ON because it restores parity rather than + * inventing an affordance: the restore is applied only on a reload or a back/forward, which are precisely + * the navigations where the browser would have restored the offset itself before this wave moved it. + * * The ceiling is `DataBrowserSettings::PAGE_SIZE_CEILING`'s, repeated rather than imported: the browser's * cap exists because one request can materialise a table into PHP memory, and this one exists because one * page can render a hundred thousand ``s into a response. Same number, different failure, and a shared @@ -49,6 +57,7 @@ public function __construct( public int $maxPageSize = 200, public string $maxHeight = '68vh', public string $density = self::DENSITY_COMFORTABLE, + public bool $rememberScroll = true, ) {} public static function fromConfig(Config $config): self @@ -62,6 +71,7 @@ public static function fromConfig(Config $config): self maxPageSize: $max, maxHeight: self::height($config->string('firefly.admin.table.max-height', '68vh')), density: self::density($config->string('firefly.admin.table.density', self::DENSITY_COMFORTABLE)), + rememberScroll: $config->bool('firefly.admin.table.remember-scroll', true), ); } diff --git a/packages/admin/tests/AdminTableScrollMemoryOffTest.php b/packages/admin/tests/AdminTableScrollMemoryOffTest.php new file mode 100644 index 00000000..a8debed7 --- /dev/null +++ b/packages/admin/tests/AdminTableScrollMemoryOffTest.php @@ -0,0 +1,17 @@ +get('/firefly/mappings')->assertStatus(200)->getContent(); + + // The page still renders its tables and its scrollport; only the memory is gone. + expect($body)->toContain('.tw{overflow:auto;max-height:var(--table-vh)}') + ->not->toContain('firefly-admin-scroll') + ->not->toContain('sessionStorage'); +}); diff --git a/packages/admin/tests/AdminTableScrollportTest.php b/packages/admin/tests/AdminTableScrollportTest.php index 47f3b457..f85d34bf 100644 --- a/packages/admin/tests/AdminTableScrollportTest.php +++ b/packages/admin/tests/AdminTableScrollportTest.php @@ -2,22 +2,51 @@ declare(strict_types=1); -use Firefly\Admin\Tests\Support\AdminCapstoneTestCase; +use Firefly\Admin\Tests\Support\AdminTableCapstoneTestCase; -uses(AdminCapstoneTestCase::class); +uses(AdminTableCapstoneTestCase::class); it('gives the table wrapper a height, which is the whole reason a sticky header can stick', function () { - /** @var AdminCapstoneTestCase $this */ + /** @var AdminTableCapstoneTestCase $this */ $body = (string) $this->get('/firefly/mappings')->assertStatus(200)->getContent(); expect($body)->toContain('--table-vh:68vh') ->toContain('.tw{overflow:auto;max-height:var(--table-vh)}'); }); -// The opt-out, because a scrollport is wrong for a panel whose rows are a fixed handful: a four-row health -// table inside a box with 68vh of height reserved for it is mostly empty box. -it('offers a panel the way out of the scrollport it does not want', function () { - /** @var AdminCapstoneTestCase $this */ - expect((string) $this->get('/firefly/mappings')->getContent()) - ->toContain('.tw.free{max-height:none}'); +/** + * THE OPT-OUT HAS TO SELECT SOMETHING, and this is the assertion that says which elements. A rule in the + * sheet is not a feature; `.tw.free` shipped for a while matching no element in any view, under a test that + * only looked for its text in the stylesheet and so could never have noticed. + * + * The set that takes it is precise: a wrapper around a table with no ``. The scrollport exists to + * give `position:sticky` something to stick to, and the overview's four panels draw headerless summary + * tables under a "All traffic →" footer link. Every other `.tw` on the dashboard draws a header and keeps + * the scrollport, which is what the second half of this asserts — an opt-out that had leaked onto the + * listings would have disabled the sticky header this wave exists to fix. + */ +it('puts the opt-out on the headerless panels that take it, and on no listing', function () { + /** @var AdminTableCapstoneTestCase $this */ + $overview = (string) $this->get('/firefly')->assertStatus(200)->getContent(); + $listing = (string) $this->get('/firefly/mappings')->assertStatus(200)->getContent(); + + expect($overview)->toContain('.tw.free{max-height:none}') + ->toContain('
') + // Every wrapper the overview draws takes the opt-out; none is left on the plain rule. + ->not->toContain('
') + ->and($listing)->toContain('
') + ->not->toContain('
'); +}); + +/** + * The scroll restore is a documented, switchable feature rather than a line of script nobody voted for. + * Its behaviour — restored on a reload, not on a fresh navigation — is measured in the browser suite + * (tests/Browser/AdminTablesTest.php); this is the proof that the key reaches the page at all. + */ +it('emits the scroll restore by default', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/mappings')->assertStatus(200)->getContent(); + + expect($body)->toContain("'firefly-admin-scroll:'") + ->toContain("entry.type === 'reload'"); }); diff --git a/packages/admin/tests/Support/AdminTableScrollMemoryOffTestCase.php b/packages/admin/tests/Support/AdminTableScrollMemoryOffTestCase.php new file mode 100644 index 00000000..57b6c505 --- /dev/null +++ b/packages/admin/tests/Support/AdminTableScrollMemoryOffTestCase.php @@ -0,0 +1,29 @@ +set()` inside the test, for the reason + * AdminTablePagedCapstoneTestCase already documents: AdminSettings — and the TableSettings hanging off it — + * is built once by AdminRouteRegistrar during the boot passes and bound as an instance, so a set from a + * test body arrives after the object that would have read it and the page still renders the default. + */ +abstract class AdminTableScrollMemoryOffTestCase extends AdminCapstoneTestCase +{ + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.table.remember-scroll' => false, + ]; + } +} diff --git a/packages/admin/tests/Table/TableSettingsTest.php b/packages/admin/tests/Table/TableSettingsTest.php index 0b87ef40..e250f9ad 100644 --- a/packages/admin/tests/Table/TableSettingsTest.php +++ b/packages/admin/tests/Table/TableSettingsTest.php @@ -20,7 +20,16 @@ function tableConfig(array $values): Config ->and($settings->pageSizes)->toBe([25, 50, 100, 200]) ->and($settings->maxPageSize)->toBe(200) ->and($settings->maxHeight)->toBe('68vh') - ->and($settings->density)->toBe(TableSettings::DENSITY_COMFORTABLE); + ->and($settings->density)->toBe(TableSettings::DENSITY_COMFORTABLE) + ->and($settings->rememberScroll)->toBeTrue(); +}); + +// ON by default because it restores parity rather than inventing an affordance: before `.tw` had a height +// the page itself was the scrollport and the browser brought the offset back across a reload for free. +it('lets a deployment decline the scroll restore', function () { + expect(TableSettings::fromConfig(tableConfig([ + 'firefly' => ['admin' => ['table' => ['remember-scroll' => false]]], + ]))->rememberScroll)->toBeFalse(); }); // The offered set is what the rows-per-page control renders, so the configured default must always be IN diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 636125f3..12ea084f 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1133,6 +1133,21 @@ // 'max-height' => env('FIREFLY_ADMIN_TABLE_MAX_HEIGHT', '68vh'), // // /* + // | Whether a table's scroll position survives a reload, saved per URL in `sessionStorage`. + // | This is the other half of `max-height`: the scrollport used to be the page itself and the + // | browser restored ITS offset for free, so the ten-second refresh cost a reader nothing. + // | Moving the scrollport into the panel took that away — a browser restores the document's + // | scroll, never an inner scroller's — and the refresh started returning readers to row 1. + // | + // | It is applied ONLY on a reload or a back/forward, which are exactly the navigations where + // | the browser would have restored it before. Clicking a page in the sidebar is a plain + // | navigation and opens the table at the top. Set to false to store nothing at all. + // | + // | Default: true. + // */ + // 'remember-scroll' => env('FIREFLY_ADMIN_TABLE_REMEMBER_SCROLL', true), + // + // /* // | 'comfortable' (14px/8px cell padding) or 'compact' (10px/5px), which fits roughly a third // | more rows on a screen. Anything unrecognised reads as 'comfortable'. The value also feeds // | the rigid column widths, which are emitted as `calc(ch + 2 * var(--row-x))` because diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php index 64ddd36a..2b677ffe 100644 --- a/tests/Browser/AdminTablesTest.php +++ b/tests/Browser/AdminTablesTest.php @@ -198,3 +198,85 @@ ->assertScript("getComputedStyle(document.querySelector('thead th')).boxShadow.includes('inset')", true) ->assertNoJavaScriptErrors(); }); + +/** + * THE OPT-OUT, MEASURED ON A REAL ELEMENT. `.tw.free` spent its first commit selecting nothing: no view in + * the package put `free` on a wrapper, and the test beside it looked for the rule's TEXT in the stylesheet, + * which a rule that can never match still satisfies. This reads the computed `max-height` off the elements + * themselves — `none` on the overview's headerless panels, a resolved length on a listing — so the day + * somebody drops the class from a view, or the specificity of the two rules flips, the assertion fails. + */ +it('frees the overview panels from the scrollport and leaves the listings inside it', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly') + ->assertScript(<<<'JS' + (() => { + const free = [...document.querySelectorAll('.tw')]; + if (free.length === 0) { return 'the overview drew no table wrappers'; } + for (const wrapper of free) { + if (!wrapper.classList.contains('free')) { return 'a headerless overview panel kept the scrollport'; } + const height = getComputedStyle(wrapper).maxHeight; + if (height !== 'none') { return 'the opt-out did not apply: max-height ' + height; } + // And it is not a scroller of its own, which is the point of declining the height. + if (wrapper.scrollHeight - wrapper.clientHeight > 1) { return 'a freed panel still scrolls'; } + } + return true; + })() + JS, true) + ->assertNoJavaScriptErrors(); + + visit('/firefly/beans?size=200') + ->assertScript(<<<'JS' + (() => { + const wrapper = document.querySelector('.tw'); + if (wrapper.classList.contains('free')) { return 'a listing took the opt-out and lost its sticky header'; } + const height = getComputedStyle(wrapper).maxHeight; + return /^\d+(\.\d+)?px$/.test(height) ? true : 'the listing has no scrollport: max-height ' + height; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); + +/** + * THE SCROLL RESTORE, BOTH HALVES. The whole reason it exists is that giving `.tw` a height took the reading + * position away from the browser: `main` used to be the scrollport and a reload brought its offset back for + * free, so the ten-second auto-refresh cost a reader nothing. This reloads the page and measures that the + * offset came back — the first half. + */ +it('brings a table back to where it was being read after a reload', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/beans?size=200') + ->assertScript(<<<'JS' + (() => { + const wrapper = document.querySelector('.tw'); + wrapper.scrollTop = 600; + return wrapper.scrollTop > 100 ? true : 'the wrapper did not scroll: ' + wrapper.scrollTop; + })() + JS, true) + ->refresh() + ->assertScript(<<<'JS' + (() => { + const wrapper = document.querySelector('.tw'); + return wrapper.scrollTop > 100 ? true : 'the reload lost the reading position: ' + wrapper.scrollTop; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); + +/** + * The second half, and the one the reviewer of the first commit asked for: a plain navigation does NOT + * restore. Arriving at Beans from the sidebar an hour later and landing in the middle of a table — with the + * document itself at the top, and nothing on screen to say why — is a defect, so the restore is applied only + * on the navigation types where the browser would have restored the document's own offset. The saved entry + * is still in `sessionStorage` when this runs: the first visit scrolled the same URL and left on `pagehide`. + */ +it('opens a table at the top when the reader navigates to it rather than reloading', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + visit('/firefly/beans?size=200') + ->assertScript("document.querySelector('.tw').scrollTop = 600, document.querySelector('.tw').scrollTop > 100", true) + ->navigate('/firefly/mappings') + ->navigate('/firefly/beans?size=200') + ->assertScript("performance.getEntriesByType('navigation')[0].type", 'navigate') + ->assertScript("document.querySelector('.tw').scrollTop", 0) + ->assertNoJavaScriptErrors(); +}); From a3444bd1aedceca97b90ba13b29a00954061c0f3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 16:40:23 -0700 Subject: [PATCH 25/78] fix(web): ellipsise a trace frame's method name inside its row instead of clipping it at the panel edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.frames .call .fn` was `flex:none` on the theory that a span which cannot shrink keeps its text. It keeps its WIDTH: its box stays as wide as its glyphs, its own `text-overflow` therefore never has a narrower box to draw an ellipsis in, and the glyphs run out of the shrunken row to be cut by `.panel{overflow:hidden}` with nothing marking the cut. Measured in Chrome over a 35-frame Laravel trace, 34 of 35 rows at 375px painted their method name up to 138px past the panel — `->whereHasMorphRelationship` arriving as `->wh` — and rows still overflowed at panel widths up to 800px. The ellipsis order is now stated with shrink factors instead of a refusal to shrink: `.dir` and `.cls` shrink at 100 and `.fn` at 1, so flexbox spends both discardable spans to nothing before taking a character off the method name, and `overflow:hidden` on `.call` keeps whatever is taken inside the row. At every panel width from 540px up that leaves 35 of 35 rows on one line with nothing past the panel. A phone cannot be answered that way: a row has 295px there and `AddQueuedCookiesToResponse.php` alone is 226 of them, so ranked shrinking ends with 23 of 35 method names cut and two rendered at zero width. Below 560px the row wraps and the call takes a line of its own, the directory joins `.pkg` and `.cls` in being dropped — it was already ellipsised to `vendor/la…` on every row — and the call keeps the same 24em guard it has everywhere rather than the old 14em one it no longer needs. Two lines a frame, 1,961px of trace where the unwrapped page measured 1,407 and the pre-wave one 17,465; nothing wraps inside a span. Also pins the dependency disclosure's honesty, which was entirely unasserted: its label counts the UNTRIMMED vendor set and the `.dn` note says how many of those the budget actually left, and both are now asserted from the report's own counts, together with the note's absence when nothing was trimmed. --- packages/web/src/Error/ErrorFrame.php | 12 ++-- packages/web/src/Error/ErrorPage.php | 77 +++++++++++++++------- packages/web/tests/Error/ErrorPageTest.php | 64 ++++++++++++++++-- tests/Browser/ErrorPagesDebugTest.php | 30 ++++++++- 4 files changed, 151 insertions(+), 32 deletions(-) diff --git a/packages/web/src/Error/ErrorFrame.php b/packages/web/src/Error/ErrorFrame.php index 794340c0..c06ea99f 100644 --- a/packages/web/src/Error/ErrorFrame.php +++ b/packages/web/src/Error/ErrorFrame.php @@ -10,7 +10,8 @@ * * Both of its halves — the path and the call — are offered SPLIT, because the page prints each on one line * and a line runs out of width. `dir()`/`base()` and `callQualifier()`/`callFunction()` are the same idea - * twice: a qualifier that may be ellipsised, and the token that identifies the frame and never may. + * twice: a qualifier the row spends first, and the token that identifies the frame and is spent last of + * all — on a phone, where the row wraps rather than shorten it, not at all. * * A raw PHP trace is forty frames of which perhaps four are the application's, and the rest are the * framework walking its own dispatch. Marking the application's frames is what turns scrolling into @@ -76,10 +77,11 @@ public function callQualifier(): string /** * The function half of $call, WITH the separator that introduces it (`->get()`, `::make()`, `\collect()`). * - * NEVER shortened, for the same reason base() is not. A Laravel trace is sixty `Illuminate\…` frames - * whose qualifiers differ by a segment or two and whose METHOD NAMES are the only tokens that tell them - * apart; a row that clipped its way to `Illuminate\Database\Eloq…` would have printed sixty identical - * lines. + * The LAST token a row shortens, for the same reason base() is never shortened at all. A Laravel trace + * is sixty `Illuminate\…` frames whose qualifiers differ by a segment or two and whose METHOD NAMES are + * the only tokens that tell them apart; a row that clipped its way to `Illuminate\Database\Eloq…` would + * have printed sixty identical lines. Having it in a span of its own is what lets the page rank it + * behind the directory and the qualifier — and, at phone width, wrap the row instead of spending it. */ public function callFunction(): string { diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index 3f996024..e83ffa05 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -215,19 +215,21 @@ private static function frame(ErrorFrame $frame, bool $open): string * The order is the order a reader scans: where in the stack, whose code, which directory, WHICH FILE, * which line, what was called. * - * WHAT THE ELLIPSIS TAKES, AND FROM WHICH END. `text-overflow:ellipsis` always drops the END of a span, - * so the only honest way to protect a token is to give it a span of its own that cannot shrink. Three - * tokens have one: the file name, the line, and the FUNCTION — `->get()` — because those are what tell - * one frame from another. A Laravel trace is sixty `Illuminate\…` frames whose method names are the - * only difference between them, and printing the call as a single span clipped exactly that away: - * sixty rows reading `Illuminate\Database\Eloq…`. So the call is split the way the path already was, - * into a shrinkable `.cls` and a `.fn` that is `flex:none`. + * WHAT THE ELLIPSIS TAKES, AND IN WHICH ORDER. `text-overflow:ellipsis` always drops the END of a span, + * so the only way to say which token gets shortened first is to give each one a span of its own. Four + * do: the directory, the file name, the line, and the call — split again into the qualifier and the + * FUNCTION, `->get()`, because a Laravel trace is sixty `Illuminate\…` frames whose method names are + * the only difference between them, and printing the call as one span clipped exactly that away: sixty + * rows reading `Illuminate\Database\Eloq…`. * - * That leaves `.dir` and `.cls` as the two spans the row is willing to lose the tail of, and the loss - * is not free: `.cls` loses a class name the neighbouring file name repeats, which costs nothing, but - * `.dir` loses its innermost directories, which is real. It is ranked last anyway — the file name, the - * line and the package badge beside it are enough to find the file, and a row has to give up something - * before it wraps. + * With the spans in place the CSS ranks them, and the ranking is the whole design: `.dir` first — + * its innermost directories are a real loss, but the file name, the line and the package badge beside + * it are enough to find the file — then `.cls`, which repeats a class name the file name already gave, + * and only then `.fn`. The file name and the line never shorten at all. What a rank does NOT mean is + * "cannot shrink": a span that refuses to shrink in a row that must keeps its full width and paints + * past the row's edge, where it is cut with no ellipsis at all, so every rank here is a shrink factor + * and `.fn`'s is simply the smallest. On a phone even the last rank is not spent: the row wraps and + * gives the call a line of its own rather than take a character off it. */ private static function row(ErrorFrame $frame): string { @@ -410,18 +412,31 @@ private static function css(): string .frames summary:focus-visible{outline:2px solid var(--brand-ink);outline-offset:-2px} .frames .ix{font-family:var(--mono);font-size:11px;color:var(--ink-3);flex:none;min-width:2.8em;text-align:right} .frames .pkg{font-size:11px;line-height:1.7;color:var(--ink-2);background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:0 5px;flex:none;max-width:13em;overflow:hidden;text-overflow:ellipsis} -.frames .dir{font-family:var(--mono);font-size:12.5px;color:var(--ink-2);min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis} +.frames .dir{font-family:var(--mono);font-size:12.5px;color:var(--ink-2);min-width:0;flex:0 100 auto;overflow:hidden;text-overflow:ellipsis} .frames .base{font-family:var(--mono);font-size:12.5px;color:var(--ink);flex:none} .frames .ln{font-family:var(--mono);font-size:12.5px;color:var(--ink-2);flex:none} /* The call is the path's twin: .cls is the qualifier and may be ellipsised, .fn is the method name and is - the token that tells sixty Illuminate frames apart, so it never shrinks. A nested flex rather than two - row items, so the pair stays glued (the row's 8px gap would print `Collection ->each()`). */ -.frames .call{font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-left:auto;min-width:0;flex:0 1 auto;display:flex;align-items:baseline} -.frames .call .cls{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis} -/* flex:none, with one guard: PHP 8.4 names a closure `{closure:/abs/path/file.php:14}`, so a "function - name" can be a hundred characters of absolute path. The cap sits far above any real method name and - bites only that case, which would otherwise push the row out past the panel. */ -.frames .call .fn{flex:none;max-width:24em;overflow:hidden;text-overflow:ellipsis} + the token that tells sixty Illuminate frames apart, so it is the LAST thing the row gives up. A nested + flex rather than two row items, so the pair stays glued (the row's 8px gap would print + `Collection ->each()`). + + WHAT "LAST" IS MADE OF, because `flex:none` did not mean it. An unshrinkable span inside a shrinking row + does not stay WHOLE, it stays WIDE: its box keeps its content width, its own `text-overflow` therefore + never has a narrower box to draw an ellipsis in, and the glyphs simply run out of the row and are cut by + `.panel{overflow:hidden}` with nothing to mark the cut. Measured in Chrome at 375px against a 35-frame + Laravel trace, 34 of 35 rows painted their method name up to 138px beyond the panel's right edge, and + `->whereHasMorphRelationship` arrived on screen as `->wh`. + So the ORDER is stated with shrink factors rather than by refusing to shrink: .dir and .cls shrink at + 100 and .fn at 1, which spends the two discardable spans down to nothing before flexbox takes a single + character off the method name — and `overflow:hidden` here means that whatever is taken is taken inside + the row, with an ellipsis, instead of outside it in silence. At a 560-920px panel that ranking leaves + every method name whole except the closure descriptor below. */ +.frames .call{font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-left:auto;min-width:0;flex:0 1 auto;display:flex;align-items:baseline;overflow:hidden} +.frames .call .cls{min-width:0;flex:0 100 auto;overflow:hidden;text-overflow:ellipsis} +/* The cap is the one guard: PHP 8.4 names a closure `{closure:/abs/path/file.php:14}`, so a "function + name" can be a hundred characters of absolute path. It sits far above any real method name and bites + only that case, which would otherwise take the whole row's width for one frame's descriptor. */ +.frames .call .fn{min-width:0;flex:0 1 auto;max-width:24em;overflow:hidden;text-overflow:ellipsis} /* The application's own frames are the point of the page; the dependency ones are context. */ .frames li.own{border-left:3px solid var(--brand);background:var(--panel)} .frames li.own .base{font-weight:600} @@ -454,8 +469,26 @@ private static function css(): string `Illuminate\Database\Eloquent\Builder` is what `Builder.php` two columns to its left already said, and `->get()` is not said anywhere else. */ .frames .call .cls{display:none} - .frames .call .fn{max-width:14em} + /* AND THEN THE PHONE ROW WRAPS, because at 375px it provably cannot do both. A row has 295px there, and + `AddQueuedCookiesToResponse.php` — a real Laravel file name, which never shortens — is 226 of them; + `:46` and `->handle` have to go somewhere. Ranked shrinking has an answer for that and it is the wrong + one: measured at 375px it spent .fn down until 23 of 35 method names had lost characters and two had + lost all of them, rendering at zero width. So the phone takes the other branch — one more line instead + of a shorter name — and the directory goes with .pkg and .cls, because it was already ellipsised to + `vendor/la…` on every row at this width and dropping it is what holds the wrapped row to two lines + instead of the four a full-width .dir forces (measured: 1,961px of trace against 3,406px). + This is NOT the pre-wave rule that made a hundred frames 10,108 pixels tall. That one wrapped the PATH + ITSELF, with overflow-wrap:anywhere, at 87px a row; nothing here wraps inside a span, and the break + can only fall between two whole tokens. */ + .frames .dir{display:none} + .frames .row,.frames summary{flex-wrap:wrap} + .frames .call{margin-left:0} .frames .ix{min-width:2.2em} + /* The call keeps the 24em guard it has everywhere and no tighter one. A phone used to cap it at 14em, + which was the right cap while the call had to share a line with a file name and was the wrong one the + moment it stopped: on its own 295px line, 14em cut `->sendRequestThroughRouter` and + `->whereHasMorphRelationship` for nothing. 24em still fits that line, and still bites the closure + descriptor it exists for. */ } CSS; } diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index b8f397da..a666f2b7 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -528,11 +528,23 @@ $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); $html = ErrorPage::render($error, $settings); + $shownVendor = count(array_filter($error->frames, static fn ($f): bool => $f->vendor)); + $allVendor = $error->frameCount - $error->appFrameCount; + expect(substr_count($html, '
  • ') + substr_count($html, '
  • '))->toBe(8) // And the header is honest about what it dropped, rather than quietly showing eight of a hundred. ->toBeLessThan($error->frameCount) ->and($html)->toContain('8 of '.$error->frameCount.' frames') - ->toContain($error->appFrameCount.' in your code'); + ->toContain($error->appFrameCount.' in your code') + // THE DISCLOSURE COUNTS THE SAME UNTRIMMED STACK THE HEADER DOES. Its label is the dependency + // frames in the whole stack, not the handful that survived the budget — `count($vendor)` there + // would read "2 frames in your dependencies" over a trace with fifty of them — and the `.dn` note + // beside it is what makes that honest rather than merely large: it says how many of those fifty + // are actually in the list below. Both halves are asserted, because a label that carries whatever + // number it is given passes a `toContain('frames in your dependencies')` either way. + ->and($allVendor)->toBeGreaterThan($shownVendor) + ->and($html)->toContain(''.$allVendor.' frames in your dependencies') + ->toContain(''.$shownVendor.' shown'); }); it('puts your frames in the list and your dependencies behind one disclosure', function () { @@ -549,7 +561,13 @@ // rather than through toBeLessThan(): strpos() answers int|false, an expectation does not narrow // the variable it was given, and comparing a possible false at level max is an error, not a style. ->and($own !== false && $deps !== false && $own < $deps)->toBeTrue() - ->and($html)->toContain('frames in your dependencies') + ->and($html)->toContain(''.($error->frameCount - $error->appFrameCount).' frames in your dependencies') + // And with nothing trimmed the note is not written at all, for the reason the header leaves out + // its "of": "32 shown" beside "32 frames in your dependencies" is a question a reader should not + // have to answer to know they are looking at the whole vendor set. The budget is stated here + // rather than assumed — a Pest stack longer than it would silently turn this into the other case. + ->and($error->frameCount)->toBeLessThanOrEqual(60) + ->and($html)->not->toContain('class="dn"') ->and(substr_count($html, '
    toBe(1) // A closed
    is a real control with real keyboard behaviour. The alternative a judge // measured — a checkbox in one div and a `~` selector reaching for a sibling of its PARENT — matches @@ -614,8 +632,6 @@ // `.call` used to be one ellipsised span, and a call is `Class->method()`: the clip took the METHOD // NAME and kept the namespace every Illuminate frame shares. The pair is now the path's pair. ->and($html)->toMatch('#[^<]+(->|::)[A-Za-z_]#') - ->toContain('.frames .call .fn{flex:none;') - ->toContain('.frames .call .cls{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}') // A phone drops the qualifier whole rather than shortening the method name. ->toContain('.frames .call .cls{display:none}') // The old single span, with the whole call inside the shrinkable box, must not come back. @@ -624,6 +640,46 @@ ->and($html)->toContain('throw'); }); +it('ranks what a narrow row gives up, and takes it inside the row rather than at the panel edge', function () { + $settings = new ErrorPageSettings(trace: true, hints: false, maxFrames: 60); + $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + // `.fn` WAS `flex:none`, on the theory that a span which cannot shrink keeps its text. It does not: it + // keeps its WIDTH, its own `text-overflow` never gets a box narrower than its glyphs to draw an + // ellipsis in, and the glyphs run out of the row to be cut by `.panel{overflow:hidden}` with nothing + // marking the cut. Measured in Chrome at 375px over a 35-frame Laravel trace, 34 of 35 rows painted + // their method name up to 138px past the panel and `->whereHasMorphRelationship` arrived as `->wh`. + expect($html) + // The rank is a shrink FACTOR, not a refusal to shrink: .dir and .cls at 100, .fn at 1, so flexbox + // spends both discardable spans to nothing before it takes a character off the method name. + ->toContain('.frames .dir{font-family:var(--mono);font-size:12.5px;color:var(--ink-2);min-width:0;flex:0 100 auto;overflow:hidden;text-overflow:ellipsis}') + ->toContain('.frames .call .cls{min-width:0;flex:0 100 auto;overflow:hidden;text-overflow:ellipsis}') + ->toContain('.frames .call .fn{min-width:0;flex:0 1 auto;max-width:24em;overflow:hidden;text-overflow:ellipsis}') + // And the row contains its own overflow, so nothing is ever cut by the panel instead. + ->toContain('display:flex;align-items:baseline;overflow:hidden}') + ->not->toContain('.frames .call .fn{flex:none') + // A PHONE DOES NOT SPEND THE LAST RANK AT ALL. At 375px a row has 295px and a real Laravel file + // name — `AddQueuedCookiesToResponse.php`, which never shortens — is 226 of them, so ranked + // shrinking alone ends with 23 of 35 method names cut and two rendered at zero width. The row wraps + // instead and the call takes a line of its own; the directory goes with `.pkg` and `.cls`, which is + // what holds the wrapped row to two lines. Nothing wraps INSIDE a span — that was the pre-wave rule + // at 87px a row — so the break can only fall between two whole tokens. + ->toContain('.frames .row,.frames summary{flex-wrap:wrap}') + ->toContain('.frames .dir{display:none}') + ->toContain('.frames .call{margin-left:0}') + // With a line to itself the call keeps the 24em guard it has everywhere: the phone's old 14em cap + // was cutting `->sendRequestThroughRouter` for room it no longer needs. + ->not->toContain('max-width:14em') + // The base rule stays nowrap; only the phone block relaxes it, which is what keeps a desktop row + // on one line at every panel width from 540px up (measured: 0 rows past the panel, 35 of 35 on one + // line). THE GEOMETRY ITSELF IS PINNED IN tests/Browser/ErrorPagesDebugTest.php, not here, by + // TRACE_CALLS_INSIDE_PANEL: `assertSee` reads text content, which is identical whether a span is + // drawn whole or clipped in half at the panel's edge, so the phone-width case measures every + // rendered `.fn` right edge against its `.panel` right edge instead. + ->toContain('flex-wrap:nowrap;white-space:nowrap}'); +}); + it('keeps every text token above 4.5:1, and the focus ring above 3:1, on every ground each is drawn on', function () { // --ink-3 was #8d95a1 in light (2.80:1 on the page ground, 3.02:1 on a panel) and #6c7883 in dark // (3.76:1 on the inset panel). It carries the call, the frame index and the panel counts — small text a diff --git a/tests/Browser/ErrorPagesDebugTest.php b/tests/Browser/ErrorPagesDebugTest.php index 4010bc6a..82797443 100644 --- a/tests/Browser/ErrorPagesDebugTest.php +++ b/tests/Browser/ErrorPagesDebugTest.php @@ -6,6 +6,32 @@ pest()->extend(BrowserTestCase::class); +/** + * True when every rendered method name sits inside the trace panel rather than being cut off at its edge. + * + * THE CHECK IS GEOMETRIC BECAUSE THE BUG WAS. A frame's `.fn` — `->handleStatefulRequest`, the token that + * tells sixty `Illuminate\…` rows apart — was `flex:none` in a row that had to shrink, so it kept its full + * WIDTH inside a box too narrow for it, never triggered its own `text-overflow`, and had its glyphs cut by + * `.panel{overflow:hidden}` instead: measured at 375px, 34 of 35 rows painted up to 138px beyond the panel + * and `->whereHasMorphRelationship` arrived as `->wh`. No `assertSee` can see that. Text content is + * identical whether a span is drawn whole or clipped in half, so the promise is asserted as a bounding box. + * + * The dependency set is opened for the measurement and put back as it was, so the screenshot taken after + * this still shows the page as a reader first meets it. + */ +const TRACE_CALLS_INSIDE_PANEL = <<<'JS' + function () { + const deps = document.querySelector('details.deps'); + const was = deps !== null && deps.open; + if (deps !== null) { deps.open = true; } + const panel = document.querySelector('section.panel.trace').getBoundingClientRect(); + const calls = Array.from(document.querySelectorAll('.frames .fn')); + const ok = calls.length > 3 && calls.every(el => el.getBoundingClientRect().right <= panel.right); + if (deps !== null) { deps.open = was; } + return ok; + } + JS; + it('renders a routing miss as the framework\'s 404 page with the trace', function (): void { /** @var BrowserTestCase $this */ visit('/does-not-exist') @@ -75,5 +101,7 @@ it('renders the 404 and the 500 at phone width', function (): void { /** @var BrowserTestCase $this */ visit('/does-not-exist')->on()->mobile()->assertSee('404')->assertSee('RESOURCE_NOT_FOUND')->assertNoJavaScriptErrors()->screenshot(filename: 'error-404-debug-mobile'); - visit('/browser-fixture/boom')->on()->mobile()->assertSee('500')->assertSee('Caused by')->assertNoJavaScriptErrors()->screenshot(filename: 'error-500-debug-mobile'); + // A phone is where the row runs out of width first, so it is where the method name is pinned: the + // `.fn` of every frame has to be inside the panel, not cut off against it. + visit('/browser-fixture/boom')->on()->mobile()->assertSee('500')->assertSee('Caused by')->assertScript(TRACE_CALLS_INSIDE_PANEL)->assertNoJavaScriptErrors()->screenshot(filename: 'error-500-debug-mobile'); }); From 570fe328b67eb64439f5eb33041d102ed07ec37f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 16:43:43 -0700 Subject: [PATCH 26/78] feat(admin): page, sort and search beans, conditions and scheduled tasks on the server --- .../admin/resources/views/beans.blade.php | 41 ++--- .../resources/views/conditions.blade.php | 40 ++--- .../admin/resources/views/scheduled.blade.php | 35 ++-- packages/admin/src/Web/AdminAction.php | 166 +++++++++++++++++- .../admin/tests/AdminTableListingTest.php | 38 ++++ .../Support/AdminTableCapstoneTestCase.php | 72 ++++++++ 6 files changed, 330 insertions(+), 62 deletions(-) diff --git a/packages/admin/resources/views/beans.blade.php b/packages/admin/resources/views/beans.blade.php index d3f4df20..5c7cd1fb 100644 --- a/packages/admin/resources/views/beans.blade.php +++ b/packages/admin/resources/views/beans.blade.php @@ -10,39 +10,34 @@
    @include('firefly-admin::_panel-head', [ - 'title' => 'Container', 'count' => count($beans), - 'filter' => 'beans-body', 'placeholder' => 'Filter by class, stereotype or interface…', + 'title' => 'Container', 'count' => $slice->total, 'query' => $query, + 'placeholder' => 'Search by class, stereotype or interface…', ]) - @if ($beans === []) - @include('firefly-admin::_empty', [ - 'title' => 'No beans registered', - 'body' => 'Check firefly.scan.paths points at your application namespace.', - ]) + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $query->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No bean\'s class, stereotype, scope, name or interfaces contain that. Show them all.'] + : ['title' => 'No beans registered', 'body' => 'Check firefly.scan.paths points at your application namespace.']) @else
    -
  • - - - @foreach ($beans as $bean) - @php $class = is_string($bean['class'] ?? null) ? $bean['class'] : ''; @endphp +
    ClassStereotypeScopeNameImplements
    + @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) + + @foreach ($slice->rows as $bean) - - - - - + + + + @endforeach
    {{ Format::shortClass($class) }}{{ rtrim(Format::namespaceOf($class), '\\') }}{{ $bean['stereotype'] ?? '' }}{{ $bean['scope'] ?? '' }}{{ $bean['name'] ?: '—' }} - @php $interfaces = is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : []; @endphp - @forelse ($interfaces as $interface) -
    {{ Format::shortClass((string) $interface) }}
    - @empty - — - @endforelse +
    + {{ Format::leafOf($bean['class']) }} + {{ Format::stemOf($bean['class']) }} {{ $bean['stereotype'] ?: '—' }}{{ $bean['scope'] ?: '—' }}{{ $bean['name'] ?: '—' }}{{ $bean['interfaces'] ?: '—' }}
    + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) @endif @endsection diff --git a/packages/admin/resources/views/conditions.blade.php b/packages/admin/resources/views/conditions.blade.php index c8f2598e..fb2dac33 100644 --- a/packages/admin/resources/views/conditions.blade.php +++ b/packages/admin/resources/views/conditions.blade.php @@ -1,11 +1,7 @@ @extends('firefly-admin::layout') @section('title', 'Conditions') @section('body') - @php - use Firefly\Admin\Format; - $positive = is_array($positiveMatches ?? null) ? $positiveMatches : []; - $negative = is_array($negativeMatches ?? null) ? $negativeMatches : []; - @endphp + @php use Firefly\Admin\Format; @endphp

    Conditions

    @@ -14,34 +10,36 @@
    - @foreach ([['Applied', $positive, 'pos-body'], ['Backed off', $negative, 'neg-body']] as [$title, $rows, $id]) + @foreach ([['Applied', $applied, $appliedQuery], ['Backed off', $backed, $backedQuery]] as [$title, $slice, $panelQuery])
    @include('firefly-admin::_panel-head', [ - 'title' => $title, 'count' => count($rows), - 'filter' => $id, 'placeholder' => 'Filter…', + 'title' => $title, 'count' => $slice->total, 'query' => $panelQuery, + 'placeholder' => 'Search by class or condition…', ]) - @if ($rows === []) - @include('firefly-admin::_empty', [ - 'title' => 'Nothing here', - 'body' => $title === 'Applied' + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $panelQuery->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No class or condition here contains that. Show them all.'] + : ['title' => 'Nothing here', 'body' => $title === 'Applied' ? 'No condition matched — unusual, and worth checking that auto-configuration is discovering your packages.' - : 'No auto-configuration found a reason to stand down. Every capability is running its framework default.', - ]) + : 'No auto-configuration found a reason to stand down. Every capability is running its framework default.']) @else
    - - - - @foreach ($rows as $row) - @php $class = is_string($row['class'] ?? null) ? $row['class'] : ''; @endphp +
    ClassCondition
    + @include('firefly-admin::_table-head', ['view' => $view, 'query' => $panelQuery]) + + @foreach ($slice->rows as $row) - - + + @endforeach
    {{ Format::shortClass($class) }}{{ rtrim(Format::namespaceOf($class), '\\') }}#[{{ Format::shortClass((string) ($row['condition'] ?? '')) }}] + {{ Format::leafOf($row['class']) }} + {{ Format::stemOf($row['class']) }} + #[{{ Format::leafOf($row['condition']) }}]
    + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $panelQuery]) @endif
    @endforeach diff --git a/packages/admin/resources/views/scheduled.blade.php b/packages/admin/resources/views/scheduled.blade.php index 593a552c..a6303d61 100644 --- a/packages/admin/resources/views/scheduled.blade.php +++ b/packages/admin/resources/views/scheduled.blade.php @@ -10,30 +10,35 @@
    - @include('firefly-admin::_panel-head', ['title' => 'Tasks', 'count' => count($tasks)]) - @if ($tasks === []) - @include('firefly-admin::_empty', [ - 'title' => 'Nothing scheduled', - 'body' => 'Add #[Scheduled] to a bean method, then run the scheduler with php artisan schedule:work.', - ]) + @include('firefly-admin::_panel-head', [ + 'title' => 'Tasks', 'count' => $slice->total, 'query' => $query, + 'placeholder' => 'Search by runnable, cron or zone…', + ]) + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $query->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No task\'s runnable, cron expression or zone contains that. Show them all.'] + : ['title' => 'Nothing scheduled', 'body' => 'Add #[Scheduled] to a bean method, then run the scheduler with php artisan schedule:work.']) @else
    - - +
    RunnableCronFixed rateFixed delayZone
    + @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) - @foreach ($tasks as $task) - @php $runnable = is_string($task['runnable'] ?? null) ? $task['runnable'] : ''; @endphp + @foreach ($slice->rows as $task) - - - - - + + + + + @endforeach
    {{ Format::shortClass($runnable) }}{{ rtrim(Format::namespaceOf($runnable), '\\') }}{{ $task['cron'] ?: '—' }}{{ $task['fixedRate'] ?: '—' }}{{ $task['fixedDelay'] ?: '—' }}{{ $task['zone'] ?: '—' }} + {{ Format::leafOf($task['runnable']) }} + {{ Format::stemOf($task['runnable']) }} + {{ $task['cron'] ?: '—' }}{{ $task['fixedRate'] ?: '—' }}{{ $task['fixedDelay'] ?: '—' }}{{ $task['zone'] ?: '—' }}
    + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) @endif
    @endsection diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 801cc8f2..a0c39118 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -428,7 +428,20 @@ private function data(Request $request, string $slug): array 'health' => ['indicators' => $this->reader->healthIndicators(), 'aggregate' => $this->aggregateStatus()], 'metrics' => ['metrics' => $this->metrics()], 'http' => ['exchanges' => $this->exchanges()], - 'beans' => ['beans' => $this->listOf('beans', 'beans')], + 'beans' => $this->listing( + $request, + 'beans', + $this->beanRows(), + TableView::of( + TableColumn::qualified('class', 'Class', weight: 5), + TableColumn::token('stereotype', 'Stereotype', weight: 2), + TableColumn::token('scope', 'Scope', weight: 1.5), + TableColumn::token('name', 'Name', weight: 2), + TableColumn::text('interfaces', 'Implements', weight: 3, sortable: true), + ), + ['class', 'stereotype', 'scope', 'name', 'interfaces'], + 'class', + ), // #[ConfigProperties] DTOs are bound and injectable but are neither scanned as components nor // produced by a factory, so the beans catalogue alone cannot see them — they arrived as // unresolved dependencies instead of as the beans they are. @@ -436,7 +449,7 @@ private function data(Request $request, string $slug): array $this->listOf('beans', 'beans'), $this->subArray($this->payload('configprops'), 'beans'), )], - 'conditions' => $this->payload('conditions') + ['positiveMatches' => [], 'negativeMatches' => []], + 'conditions' => $this->conditionsPage($request), 'mappings' => $this->listing( $request, 'mappings', @@ -450,7 +463,20 @@ private function data(Request $request, string $slug): array ['path', 'handler', 'name'], 'path', ), - 'scheduled' => ['tasks' => $this->listOf('scheduledtasks', 'tasks')], + 'scheduled' => $this->listing( + $request, + 'scheduled', + $this->taskRows(), + TableView::of( + TableColumn::qualified('runnable', 'Runnable', weight: 5), + TableColumn::token('cron', 'Cron', weight: 3), + TableColumn::number('fixedRate', 'Fixed rate', ch: 11), + TableColumn::number('fixedDelay', 'Fixed delay', ch: 11), + TableColumn::token('zone', 'Zone', weight: 2), + ), + ['runnable', 'cron', 'zone'], + 'runnable', + ), 'oauth2' => $this->oauth2(), 'env' => ['env' => $this->flatten($this->subArray($this->payload('env'), 'firefly'), 'firefly')], // Shapes verified against the real endpoints: configprops answers {beans: {class => row}} @@ -519,6 +545,45 @@ private function listing( ]; } + /** + * The two listings the Conditions page shows side by side. + * + * Each takes a QUALIFIER, so its parameters are `pos_page`/`neg_page` rather than one shared `page` — + * the same shape Spring gives a controller resolving two Pageables with `@Qualifier`. Each then + * CARRIES the other's parameters, which is the half that is easy to forget: without it, paging the + * Applied panel rebuilds a URL with no `neg_page` in it and the Backed-off panel silently jumps back + * to its first page while the reader was looking somewhere else. + * + * THE SLICES ARE REBUILT ON THE CARRYING QUERIES, and that is not ceremony. A ListingPage holds the + * query it was BUILT with and `_pager` draws every page link through `$slice->link()`, so a page + * rebuilt from the plain query would carry the panel's own position and drop its sibling's — exactly + * the bug the carrying is there to prevent, one mechanism further down. Rebuilding is free: the rows + * and the total are already computed, and `sliced()` re-derives the same effective page from them. + * + * @return array + */ + private function conditionsPage(Request $request): array + { + $view = TableView::of( + TableColumn::qualified('class', 'Class', weight: 5), + TableColumn::token('condition', 'Condition', weight: 3), + ); + + $applied = $this->listing($request, 'conditions', $this->conditionRows('positiveMatches'), $view, ['class', 'condition'], 'class', qualifier: 'pos'); + $backed = $this->listing($request, 'conditions', $this->conditionRows('negativeMatches'), $view, ['class', 'condition'], 'class', qualifier: 'neg'); + + $appliedQuery = $applied['query']->carrying($backed['query']->own()); + $backedQuery = $backed['query']->carrying($applied['query']->own()); + + return [ + 'appliedQuery' => $appliedQuery, + 'applied' => ListingPage::sliced($applied['slice']->rows, $applied['slice']->total, $appliedQuery), + 'backedQuery' => $backedQuery, + 'backed' => ListingPage::sliced($backed['slice']->rows, $backed['slice']->total, $backedQuery), + 'view' => $view, + ]; + } + /** * The route table as rows the listing engine can sort and search. * @@ -548,6 +613,101 @@ private function mappingRows(): array return $rows; } + /** + * The bean catalogue as rows. `interfaces` is flattened to a space-joined string so the column is + * searchable and orderable by the same rules as every other one — a list is neither. + * + * @return list + */ + private function beanRows(): array + { + $rows = []; + foreach ($this->listOf('beans', 'beans') as $bean) { + if (! is_array($bean)) { + continue; + } + + $interfaces = []; + foreach (is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : [] as $interface) { + $interfaces[] = Format::leafOf(is_string($interface) ? $interface : ''); + } + + $rows[] = [ + 'class' => is_string($bean['class'] ?? null) ? $bean['class'] : '', + 'stereotype' => is_string($bean['stereotype'] ?? null) ? $bean['stereotype'] : '', + 'scope' => is_string($bean['scope'] ?? null) ? $bean['scope'] : '', + 'name' => is_string($bean['name'] ?? null) ? $bean['name'] : '', + 'interfaces' => implode(' ', $interfaces), + ]; + } + + return $rows; + } + + /** + * One side of the condition report as rows. + * + * @param string $key `positiveMatches` or `negativeMatches` + * @return list + */ + private function conditionRows(string $key): array + { + $rows = []; + foreach ($this->subArray($this->payload('conditions'), $key) as $row) { + if (! is_array($row)) { + continue; + } + + $rows[] = [ + 'class' => is_string($row['class'] ?? null) ? $row['class'] : '', + 'condition' => is_string($row['condition'] ?? null) ? $row['condition'] : '', + ]; + } + + return $rows; + } + + /** + * The scheduled manifest as rows. + * + * @return list + */ + private function taskRows(): array + { + $rows = []; + foreach ($this->listOf('scheduledtasks', 'tasks') as $task) { + if (! is_array($task)) { + continue; + } + + $rows[] = [ + 'runnable' => is_string($task['runnable'] ?? null) ? $task['runnable'] : '', + 'cron' => $this->trigger($task['cron'] ?? null), + 'fixedRate' => $this->trigger($task['fixedRate'] ?? null), + 'fixedDelay' => $this->trigger($task['fixedDelay'] ?? null), + 'zone' => is_string($task['zone'] ?? null) ? $task['zone'] : '', + ]; + } + + return $rows; + } + + /** + * One #[Scheduled] trigger as a string, with an absent one as the empty string the view draws as `—`. + * + * ALL THREE TRIGGERS ARE STRINGS, which is the thing worth writing down here: `ScheduledDescriptor` + * types `cron`, `fixedRate` and `fixedDelay` as `?string` and Cadence parses the two intervals through + * `Duration::parse()`, so what the endpoint publishes is `10s` or `5m`, never a count of milliseconds. + * A normaliser that kept only `is_numeric()` values would blank every interval the scanner has ever + * produced. The two interval columns are still Number columns: right-aligned tabular mono is the right + * rendering for `30s` over `5m` in a stack, and the ordering a Number column offers compares them the + * same way the rest of the vocabulary compares anything else. + */ + private function trigger(mixed $value): string + { + return $value === null || $value === '' ? '' : $this->scalar($value); + } + /** * The overview is the page an operator leaves open, so it answers the three questions that matter * without a click: is it healthy, what is it doing, and what did it wire. diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index 90519243..f061f7af 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -126,3 +126,41 @@ ->assertSee('Nothing matches', false) ->assertSee('href="/firefly/mappings"', false); }); + +it('pages the beans catalogue on the server and types its columns', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/beans?size=25')->assertStatus(200)->getContent(); + + expect($body)->toContain('') + ->toContain('class="t-qual"') + ->toContain('total') + ->not->toContain('data-filter="beans-body"'); +}); + +it('orders the beans by a column the page draws, and refuses one it does not', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/beans?sort=scope')->assertStatus(200)->assertSee('dir=desc', false); + $this->get('/firefly/beans?sort=secret')->assertStatus(200)->assertDontSee('sort=secret', false); +}); + +/** + * Two listings share the Conditions page, so each takes a qualifier — Spring's `@Qualifier("pos") Pageable` + * in one parameter name. Paging one must not page the other, and each one's links must carry the other's + * position or the panel a reader is not looking at silently jumps back to page 1. + */ +it('pages the two conditions panels independently and carries each other position', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/conditions?pos_page=2&neg_page=3')->assertStatus(200)->getContent(); + + expect($body)->toContain('pos_page=2') + ->toContain('neg_page=3') + ->toContain('pos_sort=class') + ->toContain('neg_sort=class'); +}); + +it('pages the scheduled tasks and gives the numeric intervals a numeric column', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/scheduled')->assertStatus(200)->getContent(); + + expect($body)->toContain('
    ')->toContain('class="t-num"'); +}); diff --git a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php index 6313bfe4..de3ebe57 100644 --- a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php +++ b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php @@ -4,6 +4,10 @@ namespace Firefly\Admin\Tests\Support; +use Firefly\Context\Condition\ConditionEvaluationReport; +use Firefly\Context\Condition\ConditionOutcome; +use Firefly\Scheduling\Schedule\ScheduledDescriptor; +use Firefly\Scheduling\Schedule\ScheduledManifest; use Firefly\Web\Route\RouteDescriptor; use Firefly\Web\Route\RouteManifest; use Illuminate\Foundation\Application; @@ -29,6 +33,19 @@ * smallest size the rows-per-page control offers by default is 25, so `lastPage()` is 1 and the pager's * whole paged branch — the window, Previous/Next, the first/last jumps — never renders. A subclass that * needs more than one page overrides `routes()` and appends; see AdminTablePagedCapstoneTestCase. + * + * ROUTES ARE NOT THE ONLY LISTING THIS HARNESS HAS TO FEED. Three of the wiring pages are listings too, and + * two of them are EMPTY in a bare capstone: the scheduled manifest the parent stubs holds no tasks, and the + * condition report an auto-configured testbench produces has matches but no NON-matches — nothing backs off + * when nothing was overridden. An empty listing renders its empty state, which is a branch that proves the + * empty state and nothing about a colgroup, a sort link or a pager, so this case seeds both: + * + * - the tasks go in through `defineFireflyEnvironment`, because ScheduledTasksEndpoint is a singleton that + * takes the manifest in its CONSTRUCTOR and is resolved once during the boot passes — a manifest rebound + * from a test body arrives after the endpoint has already captured the empty one; + * - the non-matches go in after boot, because ConditionEvaluationReport is bound by ActuatorRouteRegistrar + * from the BootContext and is MUTABLE: the endpoint holds the same instance, so recording an outcome on + * it is visible to the next request. There is no earlier seam — the report does not exist until boot. */ abstract class AdminTableCapstoneTestCase extends AdminCapstoneTestCase { @@ -37,6 +54,17 @@ protected function defineFireflyEnvironment(Application $app): void parent::defineFireflyEnvironment($app); $app->instance(RouteManifest::class, new RouteManifest($this->routes())); + $app->instance(ScheduledManifest::class, new ScheduledManifest($this->tasks())); + } + + protected function setUp(): void + { + parent::setUp(); + + $report = $this->app()->make(ConditionEvaluationReport::class); + foreach ($this->backedOff() as [$class, $attribute, $reason]) { + $report->record($class, $attribute, ConditionOutcome::noMatch($reason)); + } } /** @@ -54,4 +82,48 @@ protected function routes(): array new RouteDescriptor('DELETE', '/orders/{id}', 'App\Http\OrderController', 'destroy', 204, 'orders.destroy', []), ]; } + + /** + * Two #[Scheduled] methods, one on each trigger the page has a column for. + * + * `fixedRate` IS A DURATION STRING, not a number of milliseconds — ScheduledDescriptor types all three + * triggers as `?string` and Cadence parses the intervals through Duration::parse — so `30s` is what the + * endpoint publishes and what the Fixed rate column has to render. One task carries a cron expression + * and no interval, the other an interval and no cron, which is also the pair that proves the em-dash: + * every row has exactly one trigger and three empty cells beside it. + * + * @return list + */ + protected function tasks(): array + { + return [ + new ScheduledDescriptor('App\Jobs\NightlyReconciliation', 'run', cron: '0 2 * * *', zone: 'UTC'), + new ScheduledDescriptor('App\Jobs\HeartbeatProbe', 'ping', fixedRate: '30s'), + ]; + } + + /** + * Conditions that did NOT match, so the Conditions page has two panels worth listing rather than one. + * + * The shape mirrors what auto-configuration records for real: a class, the attribute FQCN that judged + * it, and the reason naming the value observed. Two of them, because one row cannot show that the + * Backed-off panel sorts and pages on its OWN qualifier rather than on its neighbour's. + * + * @return list + */ + protected function backedOff(): array + { + return [ + [ + 'App\Cache\RedisCacheAutoConfiguration', + 'Firefly\Context\Condition\Attributes\ConditionalOnMissingBean', + 'a CacheManager bean is already defined by the application', + ], + [ + 'App\Mail\SmtpMailerAutoConfiguration', + 'Firefly\Context\Condition\Attributes\ConditionalOnProperty', + '(firefly.mail.enabled=false) did not match required value \'true\'', + ], + ]; + } } From cae57f0253279aad81d2a3d5e234c16b78c0c772 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 16:58:06 -0700 Subject: [PATCH 27/78] fix(admin): stop offering an ordering by an interval column that sorts durations by their leading digit The Fixed rate and Fixed delay columns held Cadence duration STRINGS while carrying TableColumn::number()'s default sortable: true, so RowComparator::forColumn() committed them to strnatcasecmp and ascending by rate answered 1h, 5m, 30s, 250ms. Both columns are now sortable: false, like meter() and actions(), and the trigger() docblock says why a duration string cannot be ordered here instead of clearing the ordering it offered. The scheduled test asserted the column TYPING only, and class="t-num" matched the ', false) - ->assertSee('', false); + ->assertSee('', false) + ->assertSee('', false); }); it('prints the counts as they stand, with no caveat, when a durable store answered', function () { @@ -83,6 +83,6 @@ private static function row(string $clientId, int $active): array ->assertOk() ->assertDontSee('Active counts this worker only') // `0` from oauth2_authorizations is the deployment's answer, not one worker's, so it is printed. - ->assertSee('', false) - ->assertSee('', false); + ->assertSee('', false) + ->assertSee('', false); }); diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index ac04d496..8ea3e57f 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -436,3 +436,126 @@ 'https://example.com/firefly/loggers', '/firefly/env', ]); + +/** + * THE LISTING UNIT ON THE METRICS PAGE IS THE METER, NOT THE MEASUREMENT. A meter's `COUNT` and its + * `TOTAL_TIME` are two readings of one thing, so a page that sliced by measurement could put the count on + * page 3 and the total it counts on page 4. The page therefore sorts, searches and slices METERS, and each + * meter draws however many rows it has — which is also why the fixture's `http.server.requests` carries two + * measurements: on a per-measurement listing they are two rows that can be separated, and on this one they + * cannot be. + * + * `style="width:22%"` is the hand-written column width the Relative bar used to carry, and the assertion + * that it is gone is the assertion that this table is laid out by the vocabulary rather than by one number + * somebody typed into the markup. + */ +it('pages the metrics by meter and keeps every statistic of a meter on one page', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/metrics?size=25')->assertStatus(200)->getContent(); + + expect($body)->toContain('
    rather than the t-num dim cells, so rewriting trigger() around is_numeric() would have em-dashed every trigger on the page and stayed green; it now asserts the normalised cells the fixture was built to produce and that neither interval header links. --- packages/admin/src/Web/AdminAction.php | 24 +++++++++--- .../admin/tests/AdminTableListingTest.php | 38 ++++++++++++++++++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index a0c39118..80c967ea 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -470,8 +470,8 @@ private function data(Request $request, string $slug): array TableView::of( TableColumn::qualified('runnable', 'Runnable', weight: 5), TableColumn::token('cron', 'Cron', weight: 3), - TableColumn::number('fixedRate', 'Fixed rate', ch: 11), - TableColumn::number('fixedDelay', 'Fixed delay', ch: 11), + TableColumn::number('fixedRate', 'Fixed rate', ch: 11, sortable: false), + TableColumn::number('fixedDelay', 'Fixed delay', ch: 11, sortable: false), TableColumn::token('zone', 'Zone', weight: 2), ), ['runnable', 'cron', 'zone'], @@ -699,9 +699,23 @@ private function taskRows(): array * types `cron`, `fixedRate` and `fixedDelay` as `?string` and Cadence parses the two intervals through * `Duration::parse()`, so what the endpoint publishes is `10s` or `5m`, never a count of milliseconds. * A normaliser that kept only `is_numeric()` values would blank every interval the scanner has ever - * produced. The two interval columns are still Number columns: right-aligned tabular mono is the right - * rendering for `30s` over `5m` in a stack, and the ordering a Number column offers compares them the - * same way the rest of the vocabulary compares anything else. + * produced. + * + * THAT SAME FACT IS WHY THE TWO INTERVAL COLUMNS ARE NUMBER COLUMNS THAT DO NOT SORT. Number is the right + * RENDERING — right-aligned tabular figures line `30s` up over `5m` in a stack — and the wrong + * ORDERING, because what those cells hold is text. `RowComparator::forColumn()` scans the column, + * finds a value that is not numeric on its first row, and commits every pair of it to `strnatcasecmp`, + * which orders a duration by its leading digit: ascending by Fixed rate over `250ms`, `30s`, `5m`, + * `1h` answers `1h, 5m, 30s, 250ms`, the hour first and the quarter-second last — the real ordering + * turned inside out under a header that promised it. A header that offers an ordering has to deliver + * one, so these two do not offer it, the same `sortable: false` `meter()` and `actions()` carry for a + * column with nothing to order by. + * + * Delivering it would take a comparable magnitude per row, and the parser that produces one is + * `Firefly\Resilience\Duration` — a layer Admin may not reach (Deptrac gives it Kernel, Container, + * Config, Context, AutoConfigure, Web, Actuator and Data). A second duration parser kept here to order + * a page that lists a handful of tasks is precisely the drift `RowComparator` exists to prevent, so the + * ordering stays unoffered rather than reimplemented. */ private function trigger(mixed $value): string { diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index f061f7af..4ac7e552 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -158,9 +158,43 @@ ->toContain('neg_sort=class'); }); -it('pages the scheduled tasks and gives the numeric intervals a numeric column', function () { +/** + * The trigger normaliser has two halves and this pins both, because the column TYPES prove neither: + * `class="t-num"` is the `` the head partial emits from the column definition and says nothing about + * what the cells under it hold — those render `class="t-num dim"`. + * + * What it keeps: all three triggers are STRINGS (`ScheduledDescriptor` types them `?string` and Cadence + * parses the intervals through `Duration::parse()`), so `0 2 * * *` and `30s` are what the endpoint + * publishes and what the page has to draw. A normaliser rewritten around `is_numeric()` — the obvious + * shape, and the one the plan carried — turns every interval on the page into an em-dash without failing + * a single assertion about a colgroup or a pager. What it drops: an absent trigger, which becomes the + * empty string the view draws as `—`, and every row of the fixture carries exactly one trigger. + */ +it('renders the scheduled triggers the scanner publishes and an em-dash for the ones a task lacks', function () { /** @var AdminTableCapstoneTestCase $this */ $body = (string) $this->get('/firefly/scheduled')->assertStatus(200)->getContent(); - expect($body)->toContain('')->toContain('class="t-num"'); + expect($body)->toContain('
    ') + ->toContain('class="t-num"') + ->toContain('>0 2 * * *<') + ->toContain('>UTC<') + ->toContain('>30s<') + ->toContain(''); +}); + +/** + * A Number column is a RENDERING here, not an ordering. `30s`, `5m` and `1h` are duration strings, so the + * comparison the column would get is `strnatcasecmp` and ascending by rate answers `1h, 5m, 30s, 250ms` — + * so the page does not offer the ordering at all: no link in the two interval headers, and a hand-written + * `?sort=` on one of them is refused like any other column the listing did not publish. + */ +it('offers no ordering by the interval columns it cannot order', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/scheduled') + ->assertStatus(200) + ->assertSee('sort=cron', false) + ->assertDontSee('sort=fixedRate', false) + ->assertDontSee('sort=fixedDelay', false); + + $this->get('/firefly/scheduled?sort=fixedRate')->assertStatus(200)->assertDontSee('sort=fixedRate', false); }); From 6477fa871dcc9bfca9c643d899bf607cb6106cd5 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 17:02:08 -0700 Subject: [PATCH 28/78] fix(web): open the dependency disclosure when the trace has no frame of your own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack whose every frame is under `vendor/` rendered the trace panel as a heading over a closed disclosure with not one frame visible: `frames()` emits `
      ` only when there are application frames, and `dependencies()` hardcoded a closed `
      `. `ErrorFrame::$vendor` is decided by `/vendor/` in the path alone, so this is the shape every failure raised before application code runs takes under a worker deployment — Octane, FrankenPHP worker mode, Vapor — whose front controller is itself a dependency and so never puts an application frame on the floor of the stack. The disclosure's open state is now the caller's decision and is open when it is the only thing in the panel. The `.deps` border-top is dropped when it follows the panel heading directly, which is exactly that case: the heading already draws a border-bottom and the two adjacent rules painted as one 2px line. Also anchors the browser assertion on the domain 404's path spans at `app/Orders/` rather than from the project root. The harness serves the skeleton from `/skeleton/app/…` and SourcePaths derives its root from the outermost `vendor/` segment, so the page prints `skeleton/app/Orders/` there and `app/Orders/` in a created project; the assertion pinned the created project's layout and failed in the monorepo. --- packages/web/src/Error/ErrorPage.php | 37 +++++++++-- packages/web/tests/Error/ErrorPageTest.php | 76 ++++++++++++++++++++++ tests/Browser/ErrorPagesDebugTest.php | 9 ++- 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index e83ffa05..d9dd2fc0 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -22,8 +22,9 @@ * * THE SIGNATURE IS THE TRACE, because that is what the page is FOR. A raw PHP trace is a hundred frames of * which ten are yours; here the application's frames are the list — accented, one line each, the first one - * with its source already open — and every dependency frame sits behind a single closed disclosure below - * them. That split is the entire difference between scrolling a trace and reading one, and it is drawn with + * with its source already open — and every dependency frame sits behind a single disclosure below them, + * closed while there is a list above it to read and open when there is not. That split is the entire + * difference between scrolling a trace and reading one, and it is drawn with * `
      ` and CSS — no JavaScript, so it works with scripts disabled and in whatever a container's * minimal browser turns out to be. */ @@ -140,9 +141,21 @@ private static function previous(ErrorReport $report): string * A raw PHP trace is a hundred frames of which ten are the application's, and interleaving them is what * makes a trace something to scroll rather than something to read. So the application's frames are the * LIST — accented, in stack order, the first one with source already open — and the dependencies are a - * single closed disclosure underneath. Nothing is hidden: the count is on both, and one click or one + * single disclosure underneath, closed. Nothing is hidden: the count is on both, and one click or one * Enter opens the whole set. * + * A STACK WITH NO APPLICATION FRAME IS NOT A REASON TO SHOW NOTHING. The split assumes there is + * something above the disclosure to be a list, and `ErrorFrame::$vendor` is decided by `/vendor/` in + * the path alone — so a stack has none whenever nothing application-owned is on it. Under php-fpm the + * entry script keeps that from happening: `public/index.php` is the application's, so it is the bottom + * frame of every request. It is exactly that floor a WORKER deployment removes — Octane, FrankenPHP + * worker mode and Vapor all boot from a front controller inside `vendor/` — and there every failure + * raised before application code runs (a routing miss, a 405, a container or bootstrap throw) has a + * stack that is dependencies end to end. Closing the disclosure there left the panel as a heading over + * one collapsed row with not a single frame in sight, which is a worse page than the interleaved trace + * this split replaced. So the disclosure is OPEN when it is the only thing in the panel: "nothing is + * hidden" has to hold in the case where hiding is all the page would otherwise do. + * * Every frame keeps its position in the UNTRIMMED stack (`#37`), so a split list still reads as a stack * and a budgeted one still says where its gaps are. * @@ -186,7 +199,7 @@ private static function frames(ErrorReport $report): string $html .= '
    '; } - return $html.self::dependencies($vendor, $report->frameCount - $report->appFrameCount).''; + return $html.self::dependencies($vendor, $report->frameCount - $report->appFrameCount, $own === []).''; } /** @@ -250,10 +263,19 @@ private static function row(ErrorFrame $frame): string /** * Every dependency frame behind one disclosure, with an honest count of what the budget left out. * + * CLOSED IS A CHOICE ABOUT CONTEXT, NOT A PROPERTY OF THIS SET. It is right whenever the application's + * frames are above it, because then the reader has the frames they came for and this is the stack they + * came THROUGH. When there are none — a routing miss, a 405, anything thrown before application code + * runs, or any failure at all under a front controller that lives in `vendor/` — closing it makes the + * trace panel a heading and a collapsed row with no frame visible at all, so `$open` is passed in by + * the caller rather than decided here: this set does not know whether it is the context or the whole + * trace. + * * @param list $vendor * @param int $total dependency frames in the UNTRIMMED stack + * @param bool $open true when this disclosure is the only thing in the panel */ - private static function dependencies(array $vendor, int $total): string + private static function dependencies(array $vendor, int $total, bool $open): string { if ($vendor === []) { return ''; @@ -262,7 +284,7 @@ private static function dependencies(array $vendor, int $total): string $label = $total.' frame'.($total === 1 ? '' : 's').' in your dependencies'; $note = count($vendor) === $total ? '' : ''.self::e(count($vendor).' shown').''; - $html = '
    '.self::e($label).''.$note.'' + $html = '
    '.self::e($label).''.$note.'' .'
      '; foreach ($vendor as $frame) { @@ -444,6 +466,9 @@ private static function css(): string /* De-emphasised by weight, ground and the missing rail — NOT by fading text below readable contrast. */ .frames li.vendor .base{font-weight:400;color:var(--ink-2)} .deps{border-top:1px solid var(--line);background:var(--panel-2)} +/* …except when it follows the heading directly, which is the vendor-only stack: the heading already draws + a border-bottom, and two adjacent 1px rules paint as one 2px one under a heading and nowhere else. */ +.panel h2+.deps{border-top:0} .deps>summary{display:flex;gap:12px;align-items:baseline;padding:10px 16px;cursor:pointer;list-style:none;font-size:12.5px;color:var(--ink-2)} .deps>summary::-webkit-details-marker{display:none} .deps>summary::before{content:"▸";color:var(--ink-3);font-size:10px} diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index a666f2b7..79623a74 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -4,6 +4,7 @@ use Firefly\Config\Config; use Firefly\Kernel\Exception\Business\ResourceNotFoundException; +use Firefly\Web\Error\ErrorFrame; use Firefly\Web\Error\ErrorPage; use Firefly\Web\Error\ErrorPageRenderer; use Firefly\Web\Error\ErrorPageSettings; @@ -732,3 +733,78 @@ // And the value that was failing is recorded as failing, so the swap cannot be undone by accident. ->and($ratio('#e07a17', '#faf9f6'))->toBeLessThan(3.0); }); + +it('shows the frames when not one of them is yours, instead of a heading over a closed disclosure', function () { + // THE STACK THIS PINS IS NOT A HYPOTHETICAL. `ErrorFrame::$vendor` is decided by `/vendor/` in the + // path, so a stack has no application frame whenever nothing application-owned is on it: a routing + // miss, a 405, a container or bootstrap throw — everything raised BEFORE application code runs — under + // any deployment whose front controller is itself a dependency, which is Octane, FrankenPHP worker mode + // and Vapor. The split then has nothing to put in its list, and with the disclosure hardcoded closed + // the trace panel came out as a heading over one collapsed row with not a single frame in sight: worse + // than the interleaved trace the split replaced, and a flat contradiction of "nothing is hidden". + // + // WHY THE REPORT IS BUILT BY HAND. This repository cannot produce the shape through ErrorReport::of(): + // a PHP process always has its entry script at the bottom of the stack, and here that script is a test + // file under `packages/` or `tests/` — application code by the same `/vendor/` rule. Measured against + // the wave's own browser harness, even its routing-miss 404 reports `40 of 98 frames · 9 in your code`, + // because the in-process server is driven from `tests/Browser/`. So the renderer is fed the report + // Octane hands it instead, which is the unit that has the decision: the page is a pure function of the + // report, and the report shape is the one the pipeline genuinely builds off the floor of a vendored + // front controller. + $class = new ReflectionClass(ErrorReport::class); + $constructor = $class->getConstructor(); + + expect($constructor)->not->toBeNull(); + + $report = $class->newInstanceWithoutConstructor(); + $constructor?->invokeArgs($report, [ + 'status' => 404, + 'reason' => 'Not Found', + 'code' => 'ROUTE_NOT_FOUND', + 'category' => 'business', + 'severity' => 'warning', + 'method' => 'GET', + 'path' => '/does-not-exist', + 'timestamp' => '2026-01-01T00:00:00+00:00', + 'detailed' => true, + 'exceptionClass' => 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException', + 'message' => 'The route does-not-exist could not be found.', + 'location' => 'vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php:44', + 'frames' => [ + new ErrorFrame('/srv/vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php', 'vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php', 44, 'throw', true, [], 0), + new ErrorFrame('/srv/vendor/laravel/framework/src/Illuminate/Routing/Router.php', 'vendor/laravel/framework/src/Illuminate/Routing/Router.php', 731, 'Illuminate\Routing\AbstractRouteCollection->handleMatchedRoute()', true, [], 1), + new ErrorFrame('/srv/vendor/laravel/octane/bin/swoole-server', 'vendor/laravel/octane/bin/swoole-server', 21, 'Laravel\Octane\Worker->handle()', true, [], 2), + ], + 'frameCount' => 3, + 'appFrameCount' => 0, + ]); + + $html = ErrorPage::render($report, new ErrorPageSettings(trace: true, hints: false)); + + expect($html) + // A reader meets three frames, not a closed row: the disclosure is open because it is the panel. + ->toContain('
      ') + ->toContain('3 frames in your dependencies') + ->and(substr_count($html, '
    1. '))->toBe(3) + ->and($html)->toContain('AbstractRouteCollection.php') + ->toContain('swoole-server') + // The header still counts the stack, and still says none of it is the application's. + ->toContain('3 frames · 0 in your code') + // And the one place a closed
      and an open one sit differently: the heading above it + // already draws a border-bottom, so the disclosure drops its own border-top when it follows one. + ->toContain('.panel h2+.deps{border-top:0}'); +}); + +it('leaves the dependency disclosure closed whenever there are frames above it to read', function () { + // The other side of the branch, and the one that must not move: when the split has a list, the + // dependency set is the stack the reader came THROUGH and starts collapsed. Driven through + // ErrorReport::of() rather than by hand, because this shape is the one a real run produces. + $settings = new ErrorPageSettings(trace: true, hints: false, maxFrames: 60); + $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + expect($error->appFrameCount)->toBeGreaterThan(0) + ->and($html)->toContain('
    2. ') + ->toContain('
      ') + ->not->toContain('
      '); +}); diff --git a/tests/Browser/ErrorPagesDebugTest.php b/tests/Browser/ErrorPagesDebugTest.php index 82797443..77d81e42 100644 --- a/tests/Browser/ErrorPagesDebugTest.php +++ b/tests/Browser/ErrorPagesDebugTest.php @@ -60,8 +60,15 @@ function () { // only ever matched because Playwright concatenates sibling text with no separator; it asserted a // string the document no longer contains anywhere. Both halves are named instead, so the assertion // says what the page actually promises and fails loudly if either span is dropped. + // + // THE DIRECTORY IS MATCHED BY ITS TAIL, not from the project root, because the harness's project + // root is not a created application's. The skeleton is served from `/skeleton/app/…`, and + // SourcePaths derives its root from the outermost `vendor/` segment — the monorepo — so the page + // honestly prints `skeleton/app/Orders/` here and `app/Orders/` in a created project. Anchoring the + // assertion at `app/Orders/` pins what this test is actually about — that the two spans are + // adjacent and that the file name is whole and its own — in both layouts. ->assertSee('OrderService.php') - ->assertSourceHas('app/Orders/OrderService.php') + ->assertSourceHas('app/Orders/OrderService.php') ->assertNoJavaScriptErrors() ->screenshot(filename: 'error-404-domain-debug'); }); From cdf598739f6c0161ea7ae189148c42e76716196d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 17:08:42 -0700 Subject: [PATCH 29/78] fix(web): the fact grid draws its own rules and the reference is printed once, selectable in a click --- packages/web/src/Error/ErrorPage.php | 106 ++++++++++++++++--- packages/web/src/Error/ErrorPageSettings.php | 3 + packages/web/tests/Error/ErrorPageTest.php | 103 ++++++++++++++++-- skeleton/config/firefly.php | 17 +++ 4 files changed, 208 insertions(+), 21 deletions(-) diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index d9dd2fc0..dd27ccc8 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -41,10 +41,10 @@ public static function render(ErrorReport $report, ErrorPageSettings $settings): .'' .'
      ' .self::header($report, $settings) - .self::facts($report) + .self::facts($report, $settings) .self::detail($report) .self::footer($report, $settings) - .'
      '; + .''.self::clipboard($settings, $report->reference !== '').''; } private static function header(ErrorReport $report, ErrorPageSettings $settings): string @@ -72,7 +72,17 @@ private static function header(ErrorReport $report, ErrorPageSettings $settings) return $html.''; } - private static function facts(ErrorReport $report): string + /** + * What is true about this request, as a card grid — with the reference as its own composed cell. + * + * THE GRID DRAWS ITS OWN RULES. It used to be `gap:1px` over a line-coloured container, which is a neat + * trick until the fact count is not a multiple of the column count — and the column count is + * `auto-fit`, so it is not knowable here. Six facts in four columns left two DEAD BEIGE CELLS on every + * production page. Now the container is the panel ground and each cell draws a rule up and to the left + * with an outset shadow, which the container's `overflow:hidden` clips on the first row and column; a + * ragged last row is simply panel-coloured, like the panel it is in. + */ + private static function facts(ErrorReport $report, ErrorPageSettings $settings): string { $rows = [ 'Request' => $report->method.' '.$report->path, @@ -80,17 +90,8 @@ private static function facts(ErrorReport $report): string 'Category' => $report->category, 'Severity' => $report->severity, 'When' => $report->timestamp, - 'Reference' => $report->reference, ]; - // The correlation id beside the trace id, and only when the two differ: with tracing off the - // reference IS the correlation id, and a second row repeating it would teach a reader that the two - // ids are interchangeable — which is the confusion keeping them apart exists to prevent. The - // Reference row above is untouched, label and markup both; it is what a person is told to quote. - if ($report->correlationId !== '' && $report->correlationId !== $report->reference) { - $rows['Correlation'] = $report->correlationId; - } - if ($report->detailed) { $rows['Exception'] = $report->exceptionClass; $rows['Thrown at'] = $report->location; @@ -104,9 +105,69 @@ private static function facts(ErrorReport $report): string $html .= '
      '.self::e($label).'
      '.self::e($value).'
      '; } + $html .= self::reference($report, $settings); + + // The correlation id beside the trace id, and only when the two differ: with tracing off the + // reference IS the correlation id, and a second row repeating it would teach a reader that the two + // ids are interchangeable — which is the confusion keeping them apart exists to prevent. + if ($report->correlationId !== '' && $report->correlationId !== $report->reference) { + $html .= '
      Correlation
      '.self::e($report->correlationId).'
      '; + } + return $html.''; } + /** + * The reference, as ONE artefact a reader can take away. + * + * It was printed twice — in the 5xx sentence and in a REFERENCE cell — and neither copy could be + * copied, so the single action a production page offers was "retype this uuid". Now the sentence points + * here, the cell is `user-select:all` (one click takes the whole id, with no JavaScript at all, in + * whatever a container's minimal browser turns out to be), and a copy button is offered on top of that + * where the browser can honour one. + * + * The `
      `/`
      ` pair is byte-for-byte what it was: it is what four tests and a support process both + * read, and the affordance is added BESIDE it in a second `
      ` — which a definition list allows and a + * `' + : ''; + + return '
      Reference
      '.$id.'
      ' + .'
      '.$action.'Quote this if you report the problem.
      '; + } + + /** + * Nine lines of progressive enhancement, and the only script this page carries. + * + * THE BUTTON SHIPS HIDDEN AND THIS REVEALS IT. A control that does nothing is worse than no control, and + * there are three ordinary ways for the clipboard to be unavailable: scripts off, a + * Content-Security-Policy that refuses an inline script, and a plain-http origin (navigator.clipboard is + * a secure-context API). In every one of them the button stays hidden, the select-all cell is still + * there, and the page is exactly what it was before. `firefly.web.error-page.copy-button` removes it + * entirely for a deployment whose CSP reports rather than merely blocks. + */ + private static function clipboard(ErrorPageSettings $settings, bool $rendered): string + { + if (! $settings->copyButton || ! $rendered) { + return ''; + } + + return ''; + } + private static function detail(ErrorReport $report): string { if (! $report->detailed) { @@ -344,7 +405,7 @@ private static function reassurance(int $status, string $reference): string $status === 405 => 'That address does not accept this kind of request.', $status >= 500 => $reference === '' ? 'Something went wrong on our side. The error has been logged.' - : "Something went wrong on our side. It has been logged; quote reference {$reference} if you report it.", + : 'Something went wrong on our side. It has been logged; quote the reference below if you report it.', default => 'That request could not be completed.', }; } @@ -401,10 +462,25 @@ private static function css(): string .code{margin:0;font-family:var(--mono);font-size:13px;letter-spacing:.04em;color:var(--ink-2)} .message{margin:6px 0 0;font-size:16px;line-height:1.5;color:var(--ink);overflow-wrap:anywhere} .muted{color:var(--ink-2)} -.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1px;margin:0;background:var(--line);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} -.facts>div{background:var(--panel);padding:11px 14px;min-width:0} +.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} +/* Each cell draws its own rule up and to the left. The first row's and first column's shadows fall outside + the padding box and are clipped by overflow:hidden, so nothing doubles the container border — and a + ragged last row is panel-coloured rather than the dead beige the line-coloured ground used to show. */ +.facts>div{background:var(--panel);padding:11px 14px;min-width:0;box-shadow:-1px -1px 0 var(--line)} .facts dt{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--ink-2);margin:0 0 3px} .facts dd{margin:0;font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere} +/* .fact-ref is a div child of .facts, so it already has the cell's ground, padding and rule. Only what + makes it the REFERENCE cell is declared here. One click takes the whole id — no JavaScript at all, and + no dragging a selection across a wrapped uuid. */ +.fact-ref dd:first-of-type{user-select:all;-webkit-user-select:all} +.fact-ref .ref-act{display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap} +.fact-ref .hint{font-family:var(--sans);font-size:11.5px;color:var(--ink-2)} +.copy{font:inherit;font-size:11.5px;font-family:var(--sans);color:var(--ink);background:var(--panel-2);border:1px solid var(--line-2);border-radius:6px;padding:2px 9px;cursor:pointer} +.copy:hover{background:var(--bg)} +/* The ring is --brand-ink, not --brand: the button's ground is --panel-2, where #e07a17 measures + 2.86:1 — under SC 1.4.11's 3:1 floor for a non-text indicator. Same token, same reason, as the + two summary rings below. */ +.copy:focus-visible{outline:2px solid var(--brand-ink);outline-offset:1px} .panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;min-width:0} .panel h2{margin:0;padding:12px 16px;font-size:13px;font-weight:650;border-bottom:1px solid var(--line);background:var(--panel-2);display:flex;justify-content:space-between;gap:12px;align-items:baseline} .panel h2 .n{font-weight:400;font-size:11.5px;color:var(--ink-3);font-family:var(--mono)} diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index dbe19eb1..1e7aee74 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -109,6 +109,8 @@ public function __construct( // its lede. Off, `ErrorReport::$publicDetail` is '' and there is nothing for a renderer to print. // What counts as authored is ProblemMapper's decision, not this key's — see the class comment. public bool $authoredDetail = true, + // Progressive enhancement, and the only script this page has ever carried: see ErrorPage::clipboard(). + public bool $copyButton = true, ) { $this->home = self::url($home); $this->signIn = self::url($signIn); @@ -185,6 +187,7 @@ public static function fromConfig(Config $config): self // frames in it, and a million would put the 10,108-pixel page back. maxFrames: max(1, min(500, $config->int('firefly.web.error-page.max-frames', 40))), authoredDetail: $config->bool('firefly.web.error-page.authored-detail', true), + copyButton: $config->bool('firefly.web.error-page.copy-button', true), ); } diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 79623a74..1117c664 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -286,7 +286,10 @@ expect($error->reference)->toBe('ref-1234-abcd') ->and($html)->toContain('ref-1234-abcd') - ->toContain('quote reference ref-1234-abcd if you report it') + // The prose points at the Reference cell instead of repeating the id into it: the id was printed + // TWICE on every production page, and neither copy could be copied. + ->toContain('quote the reference below if you report it') + ->toContain('
      Reference
      ref-1234-abcd
      ') // The reference is the ONLY thing the production 500 adds; the cause stays withheld. ->not->toContain('boom') ->not->toContain('RuntimeException'); @@ -305,7 +308,7 @@ expect($html)->toContain('
      Reference
      ref-5678-efgh
      ') // With the trace on the message is shown, so the reassurance sentence is not — the fact row is // where the reference lives on this variant. - ->not->toContain('quote reference') + ->not->toContain('quote the reference below') ->toContain('boom'); }); @@ -322,8 +325,9 @@ // The fact-row MARKUP, so what is asserted is the table and not a word the page happens to contain. ->and($html)->toContain('
      Reference
      4bf92f3577b34da6a3ce929d0e0e4736
      ') ->toContain('
      Correlation
      corr-42
      ') - // The reference a person is asked to quote is the one a trace search can find. - ->toContain('quote reference 4bf92f3577b34da6a3ce929d0e0e4736 if you report it'); + // The reference a person is asked to quote is the one a trace search can find, and it is named + // once, in the cell that can be selected in a single click. + ->toContain('quote the reference below if you report it'); }); it('shows no Correlation row when the reference already IS the correlation id', function () { @@ -337,8 +341,9 @@ ->and($error->correlationId)->toBe('corr-42') ->and($html)->toContain('
      Reference
      corr-42
      ') ->not->toContain('
      Correlation
      ') - // One id on the page, once: a second row holding the same value teaches a reader they are the same - // thing, which is exactly what the two members exist to keep apart. + // One id on the page, once: the cell, and the copy button's data-ref that reads it. It used to be + // the cell and a second copy spelled into prose, which teaches a reader to retype rather than + // select — and printed the same value in two places with no affordance on either. ->and(substr_count($html, 'corr-42'))->toBe(2); }); @@ -723,6 +728,9 @@ expect($html)->toContain('--brand-ink:#a1520a') ->toContain('.frames summary:focus-visible{outline:2px solid var(--brand-ink)') ->toContain('.deps>summary:focus-visible{outline:2px solid var(--brand-ink)') + // The copy button's ring is the third one, and it is measured on the same two grounds: the + // button sits on --panel-2 inside a --panel cell, which is exactly where --brand fell short. + ->toContain('.copy:focus-visible{outline:2px solid var(--brand-ink)') // Pinned as a string so the token cannot silently go back to the shape one that fails. ->not->toContain('outline:2px solid var(--brand)') ->and($ratio('#a1520a', '#ffffff'))->toBeGreaterThan(3.0) @@ -808,3 +816,86 @@ ->toContain('
      ') ->not->toContain('
      '); }); + +it('draws the fact grid on the panel ground, so a ragged last row is not a dead beige cell', function () { + // The grid is `gap:1px` over a coloured container, so the container shows through between cells — and + // through the WHOLE trailing area whenever the fact count is not a multiple of the (responsive, and + // therefore unknowable) column count. Six facts in four columns is two dead cells, which is what every + // production screenshot shows. The rules are drawn by each cell instead, outset and clipped. + $settings = new ErrorPageSettings(trace: false, hints: false); + $error = ErrorReport::of(new RuntimeException('boom'), Request::create('/x'), $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + expect($html)->toContain('.facts{display:grid') + ->toContain('background:var(--panel)') + ->toContain('.facts>div{') + ->toContain('box-shadow:-1px -1px 0 var(--line)') + ->and($html)->not->toMatch('#\.facts\{[^}]*background:var\(--line\)#') + ->not->toMatch('#\.facts\{[^}]*gap:1px#'); +}); + +it('prints the reference once, as something a reader can select in one gesture', function () { + $settings = new ErrorPageSettings(trace: false, hints: false); + $request = Request::create('/orders/42', 'GET', server: ['HTTP_X_CORRELATION_ID' => 'ref-once-1234']); + $error = ErrorReport::of(new RuntimeException('boom'), $request, $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + expect($html)->toContain('
      Reference
      ref-once-1234
      ') + ->toContain('Quote this if you report the problem.') + // The sentence points AT the cell rather than repeating the id into prose. + ->toContain('quote the reference below if you report it') + ->not->toContain('quote reference ref-once-1234') + // `user-select:all` is the affordance that needs nothing: one click takes the whole id, with + // JavaScript off, in a container's minimal browser, in a screenshot tool's headless Chromium. + ->toContain('.fact-ref dd:first-of-type{user-select:all'); +}); + +it('ships the copy button hidden and reveals it from the same script that gives it behaviour', function () { + $settings = new ErrorPageSettings(trace: false, hints: false); + $request = Request::create('/x', 'GET', server: ['HTTP_X_CORRELATION_ID' => 'ref-copy-99']); + $error = ErrorReport::of(new RuntimeException('boom'), $request, $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + expect($html)->toContain('') + ->toContain('navigator.clipboard') + // A control that does nothing is worse than no control: with scripts off, with a CSP that refuses + // an inline script, or on a plain-http origin where navigator.clipboard is undefined, the button + // stays hidden and the select-all cell is still there. + ->and(substr_count($html, '']); + $error = ErrorReport::of(new RuntimeException('boom'), $request, $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'); + $html = ErrorPage::render($error, $settings); + + expect($html)->not->toContain('') + ->toContain('<script>') + ->toContain('data-ref=""><script>'); +}); + +it('gives a 404 the same single reference cell, so one page teaches the other', function () { + // CorrelationIdFilter mints an id when the request carried none, so every rendered page has a reference + // — but only the 5xx sentence names it. The CELL is the constant: whatever the status, the id is in one + // place, selectable, with the same words under it. + $settings = new ErrorPageSettings(trace: false, hints: false); + $request = Request::create('/nope', 'GET', server: ['HTTP_X_CORRELATION_ID' => 'ref-404-aa']); + $html = ErrorPage::render( + ErrorReport::of(new NotFoundHttpException, $request, $settings, dirname(__DIR__, 4), 404, 'Not Found', '2026-01-01T00:00:00+00:00'), + $settings, + ); + + expect($html)->toContain('
      Reference
      ref-404-aa
      ') + ->toContain('Quote this if you report the problem.') + // A 404 is not "something went wrong on our side", so that sentence is not on it. + ->not->toContain('quote the reference below'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index c4c44cc2..86394d61 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -999,6 +999,23 @@ // 'actions' => env('FIREFLY_WEB_ERROR_PAGE_ACTIONS', true), // // /* + // | Whether the Reference cell offers a Copy button on top of being selectable. + // | + // | THE REFERENCE IS THE ONE THING A PRODUCTION PAGE ASKS A PERSON TO CARRY AWAY, so it is + // | printed ONCE, in a cell a single click selects whole (`user-select:all`) — that much needs + // | no JavaScript and works in whatever a container's minimal browser turns out to be. The + // | button is offered on top of it by the page's only script, nine lines that ship the control + // | `hidden` and reveal it only once `navigator.clipboard` is known to exist: with scripts off, + // | behind a CSP that refuses an inline script, or on a plain-http origin where the clipboard + // | API is undefined, no control appears that would do nothing. Turn this off and the page + // | carries no script at all, which is the answer for a deployment whose CSP blocks rather + // | than merely reports. + // | + // | Default: true. + // */ + // 'copy-button' => env('FIREFLY_WEB_ERROR_PAGE_COPY_BUTTON', true), + // + // /* // | CSV of path patterns that answer with `application/problem+json` WHATEVER the caller's // | Accept header says. Checked BEFORE the header, because the header says who is asking and // | the path says what the URL is. From 2e36e0873bfc4a39bd053315f09c481c9c375de7 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 17:18:49 -0700 Subject: [PATCH 30/78] fix(admin): search the beans catalogue by the interface names the page says it searches, and pin the conditions page links over a fixture that pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beanRows() ran every interface through Format::leafOf() BEFORE the row was built, so the `interfaces` cell — and with it the `?q=` index over that column — held only leaf names. The Beans page promises the opposite in two strings it renders verbatim: "Search by class, stereotype or interface…" in the placeholder and "class, stereotype, scope, name or interfaces" in the empty state. Pasting the name an operator actually has to hand answered a confident "Nothing matches": q=HealthIndicator found three beans and q=Firefly\Actuator\Health\HealthIndicator found none, while those same three implement exactly it; ten beans against Firefly\Actuator\Endpoint\ActuatorEndpoint, same story. It was also the one column on the page that did not follow the page's own arrangement — `class` keeps the FQCN in the row and shortens it in the view — and the truncation emptied the cell's title of its purpose, a cell reading ActuatorEndpoint carrying title="ActuatorEndpoint". The row now carries both: `interfaces` holds the leaves, which is what the column draws and therefore what it may be ordered by, and `interfacesQualified` holds the FQCNs, which is what `?q=` also looks inside and what the cell hovers. That keeps InMemoryListing's rule intact — a searched value has to be visible to the reader, and this one is visible exactly where the Class column's FQCN already was — without giving the Implements column an ordering by a namespace nobody can see. The conditions test named for paging never rendered a paged panel. Nine conditions apply in an auto-configured testbench and this harness backs two off, against a default page size of 50, so ListingPage::isPaged() was false on both panels and _pager's paged branch — the only caller of ListingPage::link() anywhere — never rendered: the pager read "1–9 of 9" and then went straight to the spacer. conditionsPage() rebuilds each slice on the CARRYING query precisely so those links keep the sibling's position, and deleting both rebuilds left all 296 tests green; what the old assertions caught was the carrying through the two GET forms, which is a different mechanism. AdminTableConditionsPagedCapstoneTestCase offers a page size of 2 through configOverrides() (AdminSettings is built during the boot passes, so a config()->set() from a test body arrives too late) and grows the backed-off side to five rows, and the new suite pins the hrefs both pagers draw — including a sibling `neg_q` that a page link would otherwise drop, widening a panel the reader never touched. Both rebuilds now fail the suite when removed. The older test keeps its assertions under a name that says what they cover. --- .../admin/resources/views/beans.blade.php | 4 +- packages/admin/src/Web/AdminAction.php | 37 +++++++-- .../tests/AdminTableConditionsPagerTest.php | 75 +++++++++++++++++++ .../admin/tests/AdminTableListingTest.php | 41 +++++++++- ...inTableConditionsPagedCapstoneTestCase.php | 65 ++++++++++++++++ 5 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 packages/admin/tests/AdminTableConditionsPagerTest.php create mode 100644 packages/admin/tests/Support/AdminTableConditionsPagedCapstoneTestCase.php diff --git a/packages/admin/resources/views/beans.blade.php b/packages/admin/resources/views/beans.blade.php index 5c7cd1fb..06d216b1 100644 --- a/packages/admin/resources/views/beans.blade.php +++ b/packages/admin/resources/views/beans.blade.php @@ -31,7 +31,9 @@
    - + {{-- The leaf names in the cell and the qualified ones on the title, exactly as the + Class column beside it: the searchable value is the one on hover. --}} + @endforeach diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 80c967ea..059496b6 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -439,7 +439,9 @@ private function data(Request $request, string $slug): array TableColumn::token('name', 'Name', weight: 2), TableColumn::text('interfaces', 'Implements', weight: 3, sortable: true), ), - ['class', 'stereotype', 'scope', 'name', 'interfaces'], + // `interfacesQualified` is searched but has no column: it is the same list the Implements + // column draws, spelled out, and the cell carries it on its `title`. See beanRows(). + ['class', 'stereotype', 'scope', 'name', 'interfaces', 'interfacesQualified'], 'class', ), // #[ConfigProperties] DTOs are bound and injectable but are neither scanned as components nor @@ -614,10 +616,27 @@ private function mappingRows(): array } /** - * The bean catalogue as rows. `interfaces` is flattened to a space-joined string so the column is - * searchable and orderable by the same rules as every other one — a list is neither. + * The bean catalogue as rows. A bean's interfaces are a LIST, and a list is neither searchable nor + * orderable by the rules the rest of the table system applies, so each is flattened to a space-joined + * string — twice, under two keys, because the two jobs want two different strings. * - * @return list + * `interfaces` HOLDS THE LEAF NAMES AND IS WHAT THE COLUMN DRAWS. Ordering a column by a value the + * reader cannot see is the same defect as sorting Fixed rate by the leading digit of a duration: a + * column of `HealthIndicator`, `ActuatorEndpoint`, `Comparable` ordered by its FQCNs comes back grouped + * by namespace under a header that promised alphabetical, and nothing about the page says why. + * + * `interfacesQualified` HOLDS THE FQCNs AND IS WHAT `?q=` ALSO LOOKS INSIDE. The fully qualified name is + * the one an operator has to hand, pasted out of the editor they came from, and pasting it used to empty + * this page while three beans implemented exactly it — the one search on this dashboard that answered + * "nothing matches" to a term that matches. Both halves of the page promise otherwise in words: the + * placeholder says "class, stereotype or interface" and the empty state names interfaces outright. + * + * SEARCHING A VALUE THE READER CANNOT SEE is InMemoryListing's one prohibition, and this does not break + * it: the qualified list is drawn on the cell's `title`, the same way the Class column keeps the FQCN + * in the row, shows the leaf and puts the whole name on hover. The truncation had emptied that title of + * its purpose too — a cell reading `ActuatorEndpoint` carrying `title="ActuatorEndpoint"`. + * + * @return list */ private function beanRows(): array { @@ -627,9 +646,12 @@ private function beanRows(): array continue; } - $interfaces = []; + $leaves = []; + $qualified = []; foreach (is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : [] as $interface) { - $interfaces[] = Format::leafOf(is_string($interface) ? $interface : ''); + $name = is_string($interface) ? $interface : ''; + $leaves[] = Format::leafOf($name); + $qualified[] = $name; } $rows[] = [ @@ -637,7 +659,8 @@ private function beanRows(): array 'stereotype' => is_string($bean['stereotype'] ?? null) ? $bean['stereotype'] : '', 'scope' => is_string($bean['scope'] ?? null) ? $bean['scope'] : '', 'name' => is_string($bean['name'] ?? null) ? $bean['name'] : '', - 'interfaces' => implode(' ', $interfaces), + 'interfaces' => implode(' ', $leaves), + 'interfacesQualified' => implode(' ', $qualified), ]; } diff --git a/packages/admin/tests/AdminTableConditionsPagerTest.php b/packages/admin/tests/AdminTableConditionsPagerTest.php new file mode 100644 index 00000000..62ea30df --- /dev/null +++ b/packages/admin/tests/AdminTableConditionsPagerTest.php @@ -0,0 +1,75 @@ +get('/firefly/conditions?pos_size=2&pos_page=2&neg_size=2&neg_page=3') + ->assertStatus(200) + ->getContent(); + + // Both panels reached the paged branch — without this the three assertions below are vacuous, because + // an unpaged pager draws no links at all and `toContain` would be asserting the absence of a bug in + // markup that was never rendered. + expect($body)->toContain('page 2 of') + ->toContain('page 3 of 3'); + + // Applied: every page link carries the Backed-off panel's size AND its page, and changes only `pos_page`. + expect($body)->toMatch('~Previous~') + ->toContain('2') + ->toContain('3'); + + // Backed off: the mirror, on the fixture's own five rows — three pages, the third of them current, and + // a first-page jump that drops its own `neg_page` while keeping the neighbour's `pos_page` whole. + expect($body)->toContain('3') + ->toContain('1') + ->toMatch('~Previous~') + ->toMatch('~Next~'); +}); + +/** + * The sibling's SEARCH across a page boundary, which is the carrying failure with the loudest symptom. + * + * A dropped `neg_page` puts the other panel back on page 1; a dropped `neg_q` widens it from one row back + * to five, so rows appear out of nowhere in a panel the reader never touched — the same class of bug as a + * page link that forgets `?q=`, one listing over. It is also the one a page-number assertion cannot catch: + * `neg_q` narrows the Backed-off panel to a single page, so its own pager stops drawing links entirely and + * only the Applied panel's links can still lose it. + */ +it('carries the other panel search through a page link, not merely through its own form', function () { + /** @var AdminTableConditionsPagedCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/conditions?pos_size=2&pos_page=2&neg_q=Redis') + ->assertStatus(200) + ->getContent(); + + // One backed-off row matches, out of the five this fixture seeds. Anchored on the element, because a + // bare `1 total` is a substring of the Applied panel's own count the day a testbench applies 21. + expect($body)->toContain('1 total') + ->toContain('RedisCacheAutoConfiguration') + ->not->toContain('WidgetAutoConfiguration'); + + expect($body)->toContain('3') + ->toMatch('~Previous~'); +}); diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index 4ac7e552..1e3adf08 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Actuator\Health\HealthIndicator; use Firefly\Admin\Tests\Support\AdminTableCapstoneTestCase; use Illuminate\Support\Str; @@ -143,12 +144,50 @@ $this->get('/firefly/beans?sort=secret')->assertStatus(200)->assertDontSee('sort=secret', false); }); +/** + * The Beans page promises, in two strings it renders verbatim, that `?q=` looks inside a bean's interfaces + * — "Search by class, stereotype or interface…" in the placeholder, and "class, stereotype, scope, name or + * interfaces" in the empty state. A qualified interface name is the form an operator actually has to hand, + * pasted out of the editor they came from, and it used to answer a confident "Nothing matches" while three + * beans in the catalogue implemented exactly it: the row held only the leaf names, because the flattening + * that made the column sortable had been applied before the row was built rather than at render time. + * + * The two are pinned TOGETHER — the leaf reading and the qualified one over the same term — because either + * alone passes on a page that searches only the other. The tbody comparison is what says they are the same + * catalogue and not merely two non-empty ones. + */ +it('narrows the beans catalogue by a qualified interface name, the way the page says it does', function () { + /** @var AdminTableCapstoneTestCase $this */ + $leaf = (string) $this->get('/firefly/beans?q=HealthIndicator')->assertStatus(200)->getContent(); + $qualified = (string) $this->get('/firefly/beans?q='.urlencode(HealthIndicator::class))->assertStatus(200)->getContent(); + + expect($qualified)->not->toContain('Nothing matches') + ->toContain('DbHealthIndicator') + // Still a NARROWING, not the whole catalogue back: the ten ActuatorEndpoint beans are not in it. + ->not->toContain('MappingsEndpoint'); + + expect(Str::between($qualified, '', ''))->toBe(Str::between($leaf, '', '')); + + // And the searched value is one the reader can see, which is InMemoryListing's rule for what may be in + // `$searchable`: the cell draws the leaves and hovers the qualified list, exactly as the Class column + // beside it has kept its FQCN on the title all along. Before this, that title repeated the cell's own + // text back at it. + expect($qualified)->toContain(''); +}); + /** * Two listings share the Conditions page, so each takes a qualifier — Spring's `@Qualifier("pos") Pageable` * in one parameter name. Paging one must not page the other, and each one's links must carry the other's * position or the panel a reader is not looking at silently jumps back to page 1. + * + * WHAT THIS FIXTURE CAN SEE IS THE QUALIFYING, and the carrying only where it rides on a header link or a + * hidden field: nine applied conditions and two backed-off ones fit on every size the rows-per-page control + * offers here, so `isPaged()` is false on both panels and `_pager`'s paged branch — the only caller of + * `ListingPage::link()` anywhere — never renders. `pos_page=2` below comes back out of the Backed-off + * panel's forms, which is the carrying rather than the paging. The page links are pinned over a fixture + * built to page, in AdminTableConditionsPagerTest. */ -it('pages the two conditions panels independently and carries each other position', function () { +it('qualifies each conditions panel and carries the other position through its forms and header links', function () { /** @var AdminTableCapstoneTestCase $this */ $body = (string) $this->get('/firefly/conditions?pos_page=2&neg_page=3')->assertStatus(200)->getContent(); diff --git a/packages/admin/tests/Support/AdminTableConditionsPagedCapstoneTestCase.php b/packages/admin/tests/Support/AdminTableConditionsPagedCapstoneTestCase.php new file mode 100644 index 00000000..c69b14e5 --- /dev/null +++ b/packages/admin/tests/Support/AdminTableConditionsPagedCapstoneTestCase.php @@ -0,0 +1,65 @@ +set()` + * from a test body arrives after the object that would have read it. And it grows the backed-off side to + * five rows, which at two a page is three pages: enough for a page in the middle, a first-page jump that + * drops its own `page` while keeping its neighbour's, and a Next with nowhere to go. + * + * The filler is deliberately `App\Widgets\…`: `?neg_q=Redis` must keep narrowing to the one backed-off + * cache row the parent seeds, so no filler class or condition may contain a term a search test looks for. + */ +abstract class AdminTableConditionsPagedCapstoneTestCase extends AdminTableCapstoneTestCase +{ + /** Three pages at two rows a page — see the class docblock for why three and not two. */ + public const int BACKED_OFF = 5; + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.table.page-sizes' => '2,25,50', + ]; + } + + /** + * @return list + */ + protected function backedOff(): array + { + $rows = parent::backedOff(); + + // Zero-padded for the same reason the route filler is: the Backed-off panel is ordered by class, + // and `…02` sorting before `…10` under one reading and after it under another would make a + // page-boundary assertion a coin toss. + for ($n = count($rows) + 1; $n <= self::BACKED_OFF; $n++) { + $suffix = str_pad((string) $n, 2, '0', STR_PAD_LEFT); + $rows[] = [ + 'App\Widgets\WidgetAutoConfiguration'.$suffix, + 'Firefly\Context\Condition\Attributes\ConditionalOnProperty', + '(firefly.widgets.'.$suffix.'.enabled=false) did not match required value \'true\'', + ]; + } + + return $rows; + } +} From 4a08a8627304558cabad7c9acc7148620e1e2bf4 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 17:31:45 -0700 Subject: [PATCH 31/78] fix(web): give the copy button a rejection arm and stop three documents from describing a page that changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copy button is revealed once `navigator.clipboard` exists, and `writeText()` can still reject from there — an unfocused document (the common DOMException), a denied `clipboard-write` permission, an embedding Permissions-Policy that omits it. The promise had no `.catch`, so in exactly the cases where the control HAS been shown the click did nothing, the label stayed "Copy", and the quietest page in the framework wrote an unhandled rejection to the console. It now reports `Copy failed` and stays clickable, and the three surfaces that called the pre-click modes exhaustive say so no longer. The same commit's prose had drifted from the same commit's code. The config reference concluded that `copy-button` "is the answer for a deployment whose CSP blocks rather than merely reports" three lines after explaining that a blocking CSP already yields a page with no control on it: the key is for the deployment whose CSP must report zero inline scripts, which is what ErrorPage::clipboard() said. reassurance()'s docblock still claimed the 5xx sentence names the reference and mirrors ProblemMapper::OPAQUE_WITH_REFERENCE, both of which this wave retired on purpose; it now records that the two surfaces share the ID and no longer the SENTENCE, and why a payload with no cell to point at keeps its id inline. docs/modules/error-handling.md still quoted the retired lede, described the Reference fact as a plain row, and left `copy-button` out of the excerpt. Pinned, so none of it can drift back in silence: ErrorPageTest asserts the emitted `.catch` beside the single-`'; + .'.then(function(){b.textContent="Copied"})' + .'.catch(function(){b.textContent="Copy failed"})})})();'; } private static function detail(ErrorReport $report): string @@ -389,12 +401,22 @@ private static function footer(ErrorReport $report, ErrorPageSettings $settings) /** * A short, honest sentence for a production page — no message, no internals. * - * The 5xx sentence names the request reference, because that is the ONE thing a reader of a production - * page can do about a failure they cannot see: quote the id, so an operator can find the log line it - * stamps. The problem document has said "quote reference " since it carried `traceId`; the page a - * person actually looks at said only that the error had been logged, which left them nothing to quote. - * The wording mirrors ProblemMapper::OPAQUE_WITH_REFERENCE so a ticket reads the same whichever form - * the failure was seen in. + * The 5xx sentence POINTS AT the request reference, because that is the ONE thing a reader of a + * production page can do about a failure they cannot see: quote the id, so an operator can find the log + * line it stamps. The problem document has said "quote reference " since it carried `traceId`; the + * page a person actually looks at said only that the error had been logged, which left them nothing to + * quote. + * + * IT NO LONGER MIRRORS ProblemMapper::OPAQUE_WITH_REFERENCE, and the divergence is deliberate rather + * than drift. This sentence used to name the id inline, word for word as the document does, so a ticket + * read the same whichever form the failure was seen in — but that printed the same uuid twice on every + * production page, in prose and in the Reference cell, with no way to copy either. The page now prints + * it ONCE, in a cell that is `user-select:all` and may carry a Copy button (see reference()), and the + * lede points there. A problem document has no cell to point at, so it keeps the id inline and keeps its + * own wording. What the two surfaces still share is the ID ITSELF — the value a trace search resolves + * and a ticket is filed with — and it is only the SENTENCE that stopped being a cross-surface + * invariant. ErrorPageTest pins that divergence against the constant, so it cannot be quietly re-decided + * in either direction. */ private static function reassurance(int $status, string $reference): string { diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 1117c664..153f0483 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -861,6 +861,14 @@ // A control that does nothing is worse than no control: with scripts off, with a CSP that refuses // an inline script, or on a plain-http origin where navigator.clipboard is undefined, the button // stays hidden and the select-all cell is still there. + // + // AND THE CLICK HAS A REJECTION ARM, because those three are the modes the REVEAL can see. + // writeText() rejects with navigator.clipboard present and the guard already passed — an unfocused + // document (an ordinary DOMException, and the common one), a denied `clipboard-write` permission, an + // embedding Permissions-Policy that omits it — and in every one of those the button has already been + // shown. Without this the click does nothing at all, the label stays "Copy", and the quietest page + // in the framework writes an unhandled promise rejection to the console. + ->toContain('.catch(function(){b.textContent="Copy failed"})') ->and(substr_count($html, '&page=2'); + + expect($html)->toContain('href="/search?page=2&q=%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E">') + ->not->toContain('') + // And the fact grid still states the PATH alone: a query string is where a token or a search a + // person would rather not screenshot tends to live, and the grid offers no action to justify it. + ->toContain('
    GET /search
    '); +}); + +it('keeps the 405 verbs when authored detail is off, because they are the framework\'s own', function () use ($page) { + // THE KEY GOVERNS DISCLOSURE, AND THERE IS NONE HERE. `authored-detail` decides whether the sentence an + // APPLICATION wrote reaches a person. Nothing of the application's is in the verb sentence: the verbs + // come off the `Allow` header the ROUTER put on its own exception, the page writes the words, and the + // same list is the `allowed` member of the document published for the same failure. Pinned because two + // docblocks and the config reference now promise an operator exactly this, and because the behaviour + // rests on nothing louder than the ORDER of two branches in lede(). + $off = new ErrorPageSettings(trace: false, hints: false, authoredDetail: false); + + expect($page(new MethodNotAllowedHttpException(['POST', 'HEAD']), $off, 405, 'Method Not Allowed', 'GET', '/orders')) + ->toContain('That address does not accept a GET request. It accepts POST.') + // Every other status DOES go back to the reassurance with the key off, which is the status-and-code + // page the key exists to offer. + ->and($page(new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'), $off, 404, 'Not Found')) + ->toContain('That page does not exist.') + ->not->toContain('Order 42 does not exist.'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 0b2eb38b..332f4bbf 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -966,6 +966,13 @@ // | is the status-and-code-only page a deployment may prefer. An application's own error view // | (`views` below) reads the same sentence as `$error->publicDetail`; this key empties it. // | + // | ONE SENTENCE IS NOT GOVERNED BY THIS KEY: a 405 still names the verbs it accepts ("That + // | address does not accept a GET request. It accepts POST.") with this off. That sentence is + // | the FRAMEWORK's — the verbs come off the `Allow` header the router put on its own + // | exception, and the page writes the words — so there is nothing of YOURS in it for this key + // | to withhold, and the same list is already the `allowed` member of the problem document + // | published for the same failure. + // | // | Only sentences that were WRITTEN for a caller are ever carried: below 500, from a // | FireflyException or from an `abort(404, '…')`. At 500 and above nothing is authored — a // | QueryException's message is the failing SQL and its bindings — and nothing is carried. diff --git a/tests/Browser/ErrorPagesProductionTest.php b/tests/Browser/ErrorPagesProductionTest.php index b8186242..7976ea27 100644 --- a/tests/Browser/ErrorPagesProductionTest.php +++ b/tests/Browser/ErrorPagesProductionTest.php @@ -11,7 +11,11 @@ visit('/does-not-exist') ->assertSee('404') ->assertSee('RESOURCE_NOT_FOUND') - ->assertSee('That page does not exist.') + // WAVE UX-E: the production lede is the sentence the problem document publishes for the same + // failure, and for a route that matches nothing that sentence is ProblemMapper::NOTHING_HERE. It + // used to be the page's own "That page does not exist." — a fourth wording of one error, beside + // the document's, the log's and the router's. + ->assertSee('There is nothing at this address.') ->assertDontSee('Stack trace') ->assertDontSee('NotFoundHttpException') ->assertDontSee('APP_DEBUG') @@ -23,7 +27,10 @@ /** @var ProductionBrowserTestCase $this */ visit('/orders/999999') ->assertSee('ORDER_NOT_FOUND') - ->assertSee('That page does not exist.') + // And here it is the APPLICATION's sentence — OrderService::NOT_FOUND_SENTENCE, the one the + // skeleton wrote and problem+json has always published as `detail`. The code was already shared + // between the two surfaces; now the sentence is, which is the whole of wave UX-E's Task 6. + ->assertSee('That order does not exist.') ->assertDontSee('OrderService.php') ->assertDontSee('ResourceNotFoundException') ->assertNoJavaScriptErrors() @@ -35,7 +42,9 @@ visit('/browser-fixture/submit') ->assertSee('405') ->assertSee('METHOD_NOT_ALLOWED') - ->assertSee('That address does not accept this kind of request.') + // The verbs, not a shrug. The router named them on its own exception and the problem document has + // published them as `allowed` all along; the page is the surface that used to throw them away. + ->assertSee('That address does not accept a GET request. It accepts POST.') ->assertDontSee('Stack trace') ->assertNoJavaScriptErrors() ->screenshot(filename: 'error-405-production'); From b6f13ca255755198dca118783d34b63ee8aa54b6 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 18:45:37 -0700 Subject: [PATCH 38/78] fix(web): offer "Try again" only where a link can keep the promise, and stop ledeing the reason phrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the second review of the action row. A LINK IS A GET, WHATEVER IT CLAIMS TO REPEAT. "Try again" was offered on every status >= 500 and its href is a plain , so a POST that 500s was handed a link carrying the address and the query and dropping the verb and the body — on a POST-only route it lands the reader on this wave's own 405 page, and on a route that answers both verbs it silently sends a different request under a label that says "again". The offer is gated on GET and HEAD now and withheld otherwise, which leaves a failed POST the two offers that are true for it: "Go home" and "Contact support". THE RETRY HREF SKIPPED THE PACKAGE'S OWN URL GUARD on the strength of a claim that is false. The docblock said the path begins with exactly one slash and therefore cannot carry a scheme or a protocol-relative //host — but //host is not the only spelling of an authority. Symfony refuses a backslash in a request target only inside Request::create(); prepareRequestUri(), the path every real request takes, neither refuses nor normalises one, so a REQUEST_URI of /\evil.example reaches path() as \evil.example and the href as /\evil.example, which a browser resolves as https://evil.example/ — the primary action on the page, during exactly the incident that produces a 5xx on an arbitrary path. ErrorPageSettings::url() has refused that spelling, and the tab/LF/CR a URL parser deletes, for every operator-supplied href since the row existed; it is public now and retry() asks it the same question, accepting the address only when it comes back unchanged and making no offer when it does not. A BARE abort(403) AUTHORED NOTHING. ProblemMapper::httpMessage() substitutes the reason phrase for an empty message — the honest answer for a document that needs some detail — so authoredDetail() answered a non-empty string for every abort() that named no sentence, and the lede printed "Forbidden" under a heading already reading "403 Forbidden". That also made the 401 and 403 reassurances dead code at the default configuration, which is the one most deployments run. The page now discards a publicDetail equal to the reason phrase or to the status text and falls through to its own sentence; nothing anybody actually wrote is affected, and the problem document is unchanged. --- packages/web/src/Error/ErrorPage.php | 100 ++++++++++++++++--- packages/web/src/Error/ErrorPageSettings.php | 21 +++- packages/web/tests/Error/ErrorPageTest.php | 100 ++++++++++++++++++- skeleton/config/firefly.php | 23 ++++- 4 files changed, 223 insertions(+), 21 deletions(-) diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index fbbe4ac7..f81035bc 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -83,7 +83,8 @@ private static function header(ErrorReport $report, ErrorPageSettings $settings) * "Order 42 does not exist.", "No such tenant.", the product's replacement for the router's 404. That is * what problem+json has always published for the same failure, and a page that said "That page does not * exist." instead made one error read two ways. Only when neither applies does the page fall back to its - * own reassurance, which is all a 5xx can honestly offer. + * own reassurance, which is all a 5xx can honestly offer — and all a BARE `abort(403)` can, for which + * "authored" is a word the document uses about a sentence nobody wrote. See authoredSentence(). * * THE 405 BRANCH SITS ABOVE THE `authored-detail` GATE, AND IT IS NOT GOVERNED BY IT — which is worth * stating because the ORDER is what decides it. That key exists to say whether the sentence an @@ -101,13 +102,46 @@ private static function lede(ErrorReport $report, ErrorPageSettings $settings): return self::methodSentence($report->method, $report->allowed); } - if ($settings->authoredDetail && $report->publicDetail !== '') { - return $report->publicDetail; + $authored = $settings->authoredDetail ? self::authoredSentence($report) : ''; + + if ($authored !== '') { + return $authored; } return self::reassurance($report->status, $report->reference); } + /** + * The sentence somebody WROTE for this failure, or '' when all that is on offer is the reason phrase. + * + * A BARE `abort(403)` IS NOT AN AUTHORED SENTENCE, and taking it for one is how the most ordinary + * failure a Laravel application produces ended up with the worst lede on this page. + * ProblemMapper::httpMessage() substitutes statusText() for an empty message — the document needs SOME + * `detail`, and "Forbidden" is the honest one there, beside a `title` a machine reads — so + * authoredDetail() answers a non-empty string for every abort() that named no sentence. Printed as the + * lede that read "403 Forbidden" over "Forbidden": the same word twice, the second time in the one slot + * on the page reserved for telling a person something they did not already know. Worse, it made the + * reassurances for 401 and 403 — "You need to sign in to see that.", "You do not have access to that." + * — DEAD CODE at the default configuration, which is the only configuration most deployments run. + * + * So the test is not "is `publicDetail` non-empty" but "does it say anything the page does not already + * say": a value equal to the reason phrase beside the status code, or to the status text for the status, + * is treated as nothing authored and the page falls through to its own sentence. Both spellings are + * checked because they are two different sources — `reason` is what the renderer was handed, `statusText` + * is what ProblemMapper substituted — and they agree only by convention. Nothing an application actually + * wrote is affected: NOTHING_HERE, "No such tenant." and "Order 42 does not exist." are none of them a + * reason phrase, and an `abort(403, 'Forbidden')` that deliberately spells the word gets the sentence + * that explains it instead, which is the better page either way. + */ + private static function authoredSentence(ErrorReport $report): string + { + if ($report->publicDetail === '' || $report->publicDetail === $report->reason) { + return ''; + } + + return $report->publicDetail === ProblemMapper::statusText($report->status) ? '' : $report->publicDetail; + } + /** * A 405 in the product's words, naming the verb that was refused and the ones that are not. * @@ -132,11 +166,13 @@ private static function methodSentence(string $method, array $allowed): string * What a reader can do next — and nothing this deployment did not configure. * * Every one of the four production screenshots ends at a fact grid: no link home, no way to sign in - * after a 401, no way to ask again after a 500. The offers are per STATUS because a wrong offer is worse - * than none — "Sign in" on a 404 tells a reader they were refused when they were not — and every href is - * either the request's own address (see retry() for how that is spelled and why it is safe) or a - * configured value that ErrorPageSettings::url() has already refused unless it is a path or an http(s) - * URL. + * after a 401, no way to ask again after a 500. The offers are per STATUS AND PER VERB, because a wrong + * offer is worse than none: "Sign in" on a 404 tells a reader they were refused when they were not, and + * "Try again" on a failed POST offers to repeat a request a link is incapable of repeating. + * + * EVERY href ON THIS PAGE HAS PASSED ErrorPageSettings::url() — the configured values when the settings + * object was built, and the request's own address in retry() — so there is exactly one vocabulary for + * what may appear here and exactly one place that knows it. */ private static function actions(ErrorReport $report, ErrorPageSettings $settings): string { @@ -153,8 +189,21 @@ private static function actions(ErrorReport $report, ErrorPageSettings $settings // A 5xx is the one failure whose reader can act without leaving the page they wanted: ask for it // again. A 4xx cannot be retried into success — the address, the verb or the permission is wrong. - if ($report->status >= 500) { - $links[] = ['href' => self::retry($report), 'label' => 'Try again']; + // + // AND ONLY IF THE REQUEST WAS A GET OR A HEAD, because A LINK CANNOT RE-ISSUE A BODY. An `` + // is a GET, whatever the request it claims to repeat: on a POST-only route it lands the reader on + // this wave's OWN 405 page ("That address does not accept a GET request. It accepts POST."), and on + // a route that answers both verbs it silently sends a DIFFERENT request — same address, no form + // fields, no idempotency — while the label says "again". The query string is carried; the verb and + // the body are not, and there is no markup that would carry them without a form and a script this + // page refuses to grow. So the offer is withheld rather than made falsely: a POST that 500s gets + // "Go home" and "Contact support", which are the two things that are actually true for it. + if ($report->status >= 500 && in_array($report->method, ['GET', 'HEAD'], true)) { + $retry = self::retry($report); + + if ($retry !== '') { + $links[] = ['href' => $retry, 'label' => 'Try again']; + } } if ($settings->home !== '') { @@ -179,7 +228,8 @@ private static function actions(ErrorReport $report, ErrorPageSettings $settings } /** - * THE REQUEST THAT FAILED, not merely the path it was addressed to. + * THE REQUEST THAT FAILED, not merely the path it was addressed to — or '' when this page declines to + * spell that address at all. * * "Try again" is the primary action on every 5xx, and in its first spelling it dropped the query string: * `$report->path` comes from Laravel's `path()`, which answers `search` for /search?q=foo&page=2, so a @@ -187,9 +237,27 @@ private static function actions(ErrorReport $report, ErrorPageSettings $settings * already typed. The word "again" is a promise about the request, and a link that re-issues a different * one breaks it silently — the page looks right, and only the reader knows what was lost. * - * IT STAYS SAFE FOR THE SAME REASON THE PATH DOES, in two halves. The path is '/'-prefixed and - * ltrim()ed, so the href begins with exactly one slash: no scheme, and no protocol-relative `//host`. - * The query is Symfony's `getQueryString()`, which percent-encodes to RFC 3986 — `"` is already `%22` + * THE ADDRESS GOES THROUGH THE SAME GUARD AS EVERY OTHER HREF ON THIS PAGE, and the first spelling of + * this method did not — it trusted `$report->path` on the strength of a claim that turns out to be + * false. That claim was: the path is '/'-prefixed and ltrim()ed, so the href begins with exactly one + * slash, so it can carry neither a scheme nor a protocol-relative `//host`. The first two clauses hold + * and the conclusion does not, because `//host` is not the only spelling of an authority. Symfony + * refuses a backslash in a request target ONLY inside `Request::create()`; `prepareRequestUri()` — the + * path every real request takes — neither refuses nor normalises one, so a REQUEST_URI of + * `/\evil.example` reaches `path()` as `\evil.example` and this method as `/\evil.example`. For a + * special scheme the URL parser's relative-slash state treats `\` exactly like `/`, so a browser reads + * that as `https://evil.example/` — the PRIMARY action on the page, pointing off the origin, during the + * incident that is exactly when a 5xx lands on an arbitrary path and a reader clicks "Try again". + * + * ErrorPageSettings::url() has refused that spelling for every operator-supplied href since the action + * row existed, along with the tab, LF and CR a parser DELETES wherever they sit. This method asks it the + * same question and accepts the address only when it comes back UNCHANGED — not merely non-empty, since + * a guard that trimmed an edge would hand back a different request than the one that failed, and this + * link's whole promise is that it is the same one. Anything else, and actions() makes no offer: a link + * the page cannot spell truthfully is worse than a row with one fewer button on it. + * + * THE QUERY IS SAFE ON ITS OWN TERMS. It is Symfony's `getQueryString()`, which percent-encodes to + * RFC 3986 — `"` is already `%22` * and `<` is `%3C` before this page escapes anything — so it cannot end the attribute, cannot introduce * a second `?`, and passes through htmlspecialchars byte for byte except for the `&` between pairs, * which becomes `&` because that is how an ampersand is spelled inside an HTML attribute value. @@ -199,6 +267,10 @@ private static function actions(ErrorReport $report, ErrorPageSettings $settings */ private static function retry(ErrorReport $report): string { + if (ErrorPageSettings::url($report->path) !== $report->path) { + return ''; + } + return $report->query === '' ? $report->path : $report->path.'?'.$report->query; } diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 5c3b7970..76da6756 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -55,6 +55,13 @@ * goes back to the generic reassurance for the status, and there is no authored sentence for any renderer * — this page or an application's own error view, which is handed the same report — to reach for at all. * + * A BARE `abort(403)` AUTHORED NOTHING, and the page reads it that way whatever this key says. The REPORT + * still carries "Forbidden", because that is what the problem document publishes as `detail` and this + * property mirrors the document; the PAGE declines to lede with a word already printed beside the status + * code, and says "You do not have access to that." instead. That rule lives in + * ErrorPage::authoredSentence(), on the page, because it is a judgement about what a person is told rather + * than about what a caller is sent. + * * WITH ONE EXCEPTION, AND IT IS NOT AN AUTHORED SENTENCE. A 405 the router raised keeps its verb sentence * — "That address does not accept a GET request. It accepts POST." — whichever way this key is set, because * nothing in it came from the application: ProblemMapper reads the verbs off the `Allow` header the ROUTER @@ -224,7 +231,17 @@ private static function views(array $configured): array } /** - * An operator-supplied URL, or '' when it is not one this page will put in an href. + * A URL this page will put in an `href`, or '' when it is not one. + * + * IT IS PUBLIC BECAUSE IT IS THE PACKAGE'S WHOLE VOCABULARY FOR "SAFE HERE", and there is one href on + * the page that is not operator-supplied: the "Try again" link, which is the address the REQUEST was + * sent to. That one skipped this method in its first spelling and trusted Laravel's `path()` instead — + * and `path()` will hand back `\evil.example` for a REQUEST_URI of `/\evil.example`, because Symfony + * rejects a backslash in a request target only in `Request::create()`, never in the `prepareRequestUri()` + * path a real request takes. Prefixed with a slash that is `/\evil.example`, which the paragraph below + * spends nine lines explaining is an authority wearing a path's clothes. A second guard would have been + * a second thing to keep right; ErrorPage::retry() calls THIS one and refuses any address it alters or + * drops. The constructor's three assignments below are the other callers. * * THE ATTACK THIS CLOSES. These values arrive from configuration, which in a real deployment means a * templated environment variable — a Helm value, a CI-rendered .env, a tenant-provisioning job. The page @@ -267,7 +284,7 @@ private static function views(array $configured): array * method already refuses: `//evil.test` into `//evil.test`, `/\evil.test` into `/\evil.test`, * `javascript:…` into `javascript:…`. */ - private static function url(string $value): string + public static function url(string $value): string { $value = trim($value, "\x00..\x20"); diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 548bbab7..cf5390cf 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -16,6 +16,7 @@ use Illuminate\Config\Repository; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -974,8 +975,9 @@ $settings = new ErrorPageSettings(trace: false, hints: false, home: '/', support: 'https://support.example.test'); expect($page(new RuntimeException('boom'), $settings, 500, 'Internal Server Error', 'GET', '/orders/42')) - // A plain link to the request that failed: it re-issues a GET, it works with scripts off, and it - // cannot carry a scheme because $report->path is built as '/'.ltrim($request->path(), '/'). + // A plain link to the request that failed: it re-issues a GET, it works with scripts off, and the + // address it names came back UNCHANGED from ErrorPageSettings::url(), the same guard every + // configured href on this page passed. See the two tests below for the halves of that sentence. ->toContain('Try again') ->toContain('Go home') ->toContain('Contact support'); @@ -1091,3 +1093,97 @@ ->toContain('That page does not exist.') ->not->toContain('Order 42 does not exist.'); }); + +it('does not offer to re-issue a request a link cannot re-issue', function () use ($page) { + // A LINK IS A GET, WHATEVER IT CLAIMS TO REPEAT. "Try again" was offered on every status >= 500 and its + // href is a plain ``, so a POST that 500s was handed a link that carries the address and the query + // and drops the verb and the body — on a POST-only route it lands the reader on this wave's own 405 + // page, and on a route that answers both verbs it sends a DIFFERENT request under a label that says + // "again". The two offers that remain are the two that are true for a failed POST. + $settings = new ErrorPageSettings(trace: false, hints: false, home: '/', support: 'https://support.example.test'); + + expect($page(new RuntimeException('boom'), $settings, 500, 'Internal Server Error', 'POST', '/orders')) + ->toContain('class="acts"') + ->toContain('Go home') + ->toContain('Contact support') + ->not->toContain('Try again') + // A HEAD is a GET without a body, so it is the one other verb a link repeats faithfully. + ->and($page(new RuntimeException('boom'), $settings, 500, 'Internal Server Error', 'HEAD', '/orders')) + ->toContain('>Try again') + // And the verbs that carry a body are refused one by one rather than by a rule about POST alone. + ->and($page(new RuntimeException('boom'), $settings, 500, 'Internal Server Error', 'PUT', '/orders/42')) + ->not->toContain('Try again') + ->and($page(new RuntimeException('boom'), $settings, 500, 'Internal Server Error', 'PATCH', '/orders/42')) + ->not->toContain('Try again') + ->and($page(new RuntimeException('boom'), $settings, 500, 'Internal Server Error', 'DELETE', '/orders/42')) + ->not->toContain('Try again'); +}); + +it('puts the request\'s own address through the guard every other href on the page passed', function () { + // `/\host` IS AN AUTHORITY WEARING A PATH'S CLOTHES, and it reaches `path()` intact. Symfony refuses a + // backslash in a request target only inside Request::create(), so this request is built the way a real + // one is — from a raw $_SERVER array through prepareRequestUri(), which neither refuses nor normalises + // it. `path()` answers `\evil.example`; '/'.ltrim(…) makes `/\evil.example`; and for a special scheme + // the URL parser treats `\` exactly like `/`, so a browser resolves that as https://evil.example/. It + // would have been the PRIMARY action on the page, during the incident — a 5xx on an arbitrary path — + // that is precisely when a reader clicks "Try again". ErrorPageSettings::url() has refused this + // spelling for every configured href since the row existed; retry() asks it the same question now. + $settings = new ErrorPageSettings(trace: false, hints: false, home: '/'); + + $render = static fn (string $requestUri): string => ErrorPage::render( + ErrorReport::of( + new RuntimeException('boom'), + new Request([], [], [], [], [], [ + 'REQUEST_URI' => $requestUri, + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'app.test', + 'HTTP_HOST' => 'app.test', + 'SERVER_PORT' => '80', + ]), + $settings, + dirname(__DIR__, 4), + 500, + 'Internal Server Error', + '2026-01-01T00:00:00+00:00', + ), + $settings, + ); + + // The address is asserted absent from every HREF, not from the page: the fact grid legitimately states + // "GET /\evil.example", because that is what the request WAS and a statement is not a destination. + expect($render('/\evil.example')) + ->not->toContain('href="/\\') + // No offer at all rather than a fallback dressed as one: a link the page cannot spell truthfully is + // worse than a row with one fewer button on it, and "Go home" already says where home is. + ->not->toContain('Try again') + ->toContain('Go home') + // A tab, an LF or a CR is DELETED by the parser wherever it sits, so `//evil.example` IS + // `//evil.example` by the time anything reads it. Same guard, same refusal. + ->and($render("/\t/evil.example")) + ->not->toContain("href=\"/\t") + ->not->toContain('Try again') + // And an ordinary address is still offered, so the guard is a filter and not an off switch. + ->and($render('/orders/42')) + ->toContain('Try again'); +}); + +it('says something a reader does not already know, or falls back to the sentence that does', function () use ($page) { + // THE MOST ORDINARY FAILURE A LARAVEL APPLICATION PRODUCES is `abort(403)` with no message, and the + // problem document needs SOME `detail` for it — ProblemMapper::httpMessage() substitutes statusText(), + // which is the honest answer there, beside a `title` a machine reads. Taken for an authored sentence it + // printed "Forbidden" as the lede of a page already headed "403 Forbidden": the same word twice, in the + // one slot reserved for telling a person something new, and the 401 and 403 reassurances below became + // dead code at the DEFAULT configuration. + $settings = new ErrorPageSettings(trace: false, hints: false, home: '/', signIn: '/login'); + + expect($page(new HttpException(403, ''), $settings, 403, 'Forbidden')) + ->toContain('

    You do not have access to that.

    ') + ->and($page(new HttpException(401, ''), $settings, 401, 'Unauthorized')) + ->toContain('

    You need to sign in to see that.

    ') + ->and($page(new HttpException(429, ''), $settings, 429, 'Too Many Requests')) + ->toContain('

    That request could not be completed.

    ') + // A sentence somebody actually wrote is untouched, which is the whole point of the key. + ->and($page(new HttpException(403, 'Your trial ended on the 3rd.'), $settings, 403, 'Forbidden')) + ->toContain('Your trial ended on the 3rd.') + ->not->toContain('You do not have access to that.'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 332f4bbf..7cb8bfb5 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -977,15 +977,26 @@ // | FireflyException or from an `abort(404, '…')`. At 500 and above nothing is authored — a // | QueryException's message is the failing SQL and its bindings — and nothing is carried. // | + // | A BARE `abort(403)` NAMED NO SENTENCE, and the page does not pretend otherwise. The + // | problem document needs some `detail` for it and publishes the reason phrase ("Forbidden"), + // | which is the honest answer beside a machine-readable `title`; as a page LEDE it would be + // | the same word the reader has already met beside the status code. So the page falls through + // | to its own sentence — "You do not have access to that." — and the document is unchanged. + // | // | Default: true. // */ // 'authored-detail' => env('FIREFLY_WEB_ERROR_PAGE_AUTHORED_DETAIL', true), // // /* // | WHERE A READER CAN GO NEXT. The page offers the action that fits the status — a 401 gets - // | `sign-in`, a 5xx gets "Try again" (a plain link to the same path), everything gets `home` - // | and `support` when they are set — and offers nothing it was not given: no route name is - // | guessed, and an empty value simply produces no link. + // | `sign-in`, everything gets `home` and `support` when they are set — and offers nothing it + // | was not given: no route name is guessed, and an empty value simply produces no link. + // | + // | "Try again" is offered on a 5xx AND ONLY FOR A GET OR A HEAD, because it is a plain link + // | and a link is a GET: it carries the address and the query string of the request that + // | failed and it cannot carry the verb or the body. On a failed POST it would either land the + // | reader on the 405 page or send a different request under a label that says "again", so it + // | is withheld and that page offers `home` and `support` instead. // | // | EACH OF THESE IS A SCHEME-GUARDED URL. They reach an `href`, and htmlspecialchars escapes // | quotes and brackets and nothing about a SCHEME — so a `javascript:` value arriving from a @@ -999,6 +1010,12 @@ // | too: a value that reaches the environment with a trailing newline — a Helm block scalar, a // | here-doc-rendered variable — is still the URL you meant, and is kept. // | + // | THE "Try again" ADDRESS PASSES THE SAME GUARD, even though no operator configured it. It + // | is the request's own path, and a request target reaches the framework with a backslash + // | intact — so `/\host/…` is a real spelling a real client can send, and it is refused here + // | exactly as a configured one would be. When it is, the page makes no retry offer at all + // | rather than pointing somewhere it did not mean. + // | // | Defaults: home '/', sign-in '', support '', actions true. // */ // 'home' => env('FIREFLY_WEB_ERROR_PAGE_HOME', '/'), From 0211d2e61ff7c8c34d347e6bad8a472e507d7626 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 18:50:54 -0700 Subject: [PATCH 39/78] feat(admin): page metrics by meter, HTTP traffic newest-first and the OAuth2 clients on the server --- packages/admin/resources/views/http.blade.php | 38 +-- .../admin/resources/views/metrics.blade.php | 54 +++-- .../admin/resources/views/oauth2.blade.php | 114 ++++----- packages/admin/src/Web/AdminAction.php | 222 ++++++++++++++++-- .../tests/AdminOAuth2PageProcessLocalTest.php | 8 +- .../admin/tests/AdminTableListingTest.php | 123 ++++++++++ .../Support/AdminTableCapstoneTestCase.php | 122 ++++++++++ .../Fixtures/HttpExchangesEndpointStub.php | 52 ++++ .../Support/Fixtures/MetricsEndpointStub.php | 64 +++++ .../Fixtures/OAuth2ClientsEndpointStub.php | 54 +++++ .../tests/Admin/AdminOAuth2PageFlowTest.php | 10 +- tests/Browser/ObservabilityTest.php | 11 +- 12 files changed, 733 insertions(+), 139 deletions(-) create mode 100644 packages/admin/tests/Support/Fixtures/HttpExchangesEndpointStub.php create mode 100644 packages/admin/tests/Support/Fixtures/MetricsEndpointStub.php create mode 100644 packages/admin/tests/Support/Fixtures/OAuth2ClientsEndpointStub.php diff --git a/packages/admin/resources/views/http.blade.php b/packages/admin/resources/views/http.blade.php index 5e3cc13f..3839640e 100644 --- a/packages/admin/resources/views/http.blade.php +++ b/packages/admin/resources/views/http.blade.php @@ -12,33 +12,37 @@
    @include('firefly-admin::_panel-head', [ - 'title' => 'Exchanges', 'count' => count($exchanges), - 'filter' => 'http-body', 'placeholder' => 'Filter by path, status or id…', + 'title' => 'Exchanges', 'count' => $slice->total, 'query' => $query, + 'placeholder' => 'Search by path, status or id…', ]) - @if ($exchanges === []) - @include('firefly-admin::_empty', [ - 'title' => 'No exchanges recorded', - 'body' => 'Recording is off, or nothing has been served since this process started. Under PHP-FPM the buffer must be cache-backed to survive a request — see firefly.observability.httpexchanges.', - ]) + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $query->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No exchange\'s path, method, status or id contains that. Show them all.'] + : ['title' => 'No exchanges recorded', 'body' => 'Recording is off, or nothing has been served since this process started. Under PHP-FPM the buffer must be cache-backed to survive a request — see firefly.observability.httpexchanges.']) @else
    -
    —{{ $bean['stereotype'] ?: '—' }} {{ $bean['scope'] ?: '—' }} {{ $bean['name'] ?: '—' }}{{ $bean['interfaces'] ?: '—' }}{{ $bean['interfaces'] ?: '—' }}
    HealthIndicator
    - +
    WhenMethodPathStatusTookCorrelationTrace
    + @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) + {{-- The id stays on the although the JavaScript row filter that named it is + gone: tests/Browser/ObservabilityTest.php reaches into `#http-body` to read the + Trace column's `title` back out of the DOM, which is the one assertion in this + wave's reach that can prove a trace id survived the whole round trip. --}} - @foreach ($exchanges as $exchange) + @foreach ($slice->rows as $exchange) - - - - - - - + + + + + + + @endforeach
    {{ $exchange['timestamp'] > 0 ? Format::since($exchange['timestamp'], $now) : '—' }}{{ $exchange['method'] }}{{ $exchange['path'] }}{{ $exchange['status'] ?: '—' }}{{ $exchange['duration'] }}{{ $exchange['correlationId'] !== '' ? substr($exchange['correlationId'], 0, 8) : '—' }}{{ $exchange['traceId'] !== '' ? substr($exchange['traceId'], 0, 8) : '—' }}{{ $exchange['timestamp'] > 0 ? Format::since($exchange['timestamp'], $now) : '—' }}{{ $exchange['method'] }}{{ $exchange['path'] }}{{ $exchange['status'] ?: '—' }}{{ $exchange['duration'] }}{{ $exchange['correlationId'] !== '' ? substr($exchange['correlationId'], 0, 8) : '—' }}{{ $exchange['traceId'] !== '' ? substr($exchange['traceId'], 0, 8) : '—' }}
    + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) @endif @endsection diff --git a/packages/admin/resources/views/metrics.blade.php b/packages/admin/resources/views/metrics.blade.php index 7703e96e..19cb7abb 100644 --- a/packages/admin/resources/views/metrics.blade.php +++ b/packages/admin/resources/views/metrics.blade.php @@ -1,10 +1,7 @@ @extends('firefly-admin::layout') @section('title', 'Metrics') @section('body') - @php - $peak = 0.0; - foreach ($metrics as $metric) { foreach ($metric['rows'] as $row) { $peak = max($peak, abs($row['value'])); } } - @endphp + @php use Firefly\Admin\Format; @endphp

    Metrics

    @@ -14,34 +11,46 @@
    @include('firefly-admin::_panel-head', [ - 'title' => 'Meters', 'count' => count($metrics), - 'filter' => 'metrics-body', 'placeholder' => 'Filter meters…', + 'title' => 'Meters', 'count' => $slice->total, 'query' => $query, 'placeholder' => 'Search meters…', ]) - @if ($metrics === []) - @include('firefly-admin::_empty', [ - 'title' => 'Nothing recorded yet', - 'body' => 'The default registry keeps meters in process memory, so under PHP-FPM a page only ever sees its own request. Set firefly.observability.metrics.store to a cache store to accumulate across workers.', - ]) + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $query->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No meter name or statistic contains that. Show them all.'] + : ['title' => 'Nothing recorded yet', 'body' => 'The default registry keeps meters in process memory, so under PHP-FPM a page only ever sees its own request. Set firefly.observability.metrics.store to a cache store to accumulate across workers.']) @else
    - - - - @foreach ($metrics as $metric) +
    MeterStatisticValueRelative
    + @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) + + @foreach ($slice->rows as $metric) + {{-- ONE ROW PER MEASUREMENT, ONE SLICE PER METER. The listing pages meters, so a + meter's statistics are drawn together however many there are, and only the + first of them names the meter — the blank cells under it are what says "these + readings are of one thing". --}} @forelse ($metric['rows'] as $row) - - - - + + + @empty - + @endforelse @@ -49,6 +58,7 @@
    {{ $loop->first ? $metric['name'] : '' }}{{ $row['statistic'] }}{{ $row['display'] }} - {{-- One shared scale across every meter: the bar answers "which of these is - large", which is the only comparison a mixed-unit list supports. --}} + + @if ($loop->first) + {{ Format::leafOf($metric['name'], '.') }} + {{ Format::stemOf($metric['name'], '.') }} + @endif + {{ $row['statistic'] }}{{ $row['display'] }} + {{-- One shared scale across every meter, and across every PAGE of them: + the bar answers "which of these is large", which is the only + comparison a mixed-unit list supports, and an answer that changed + when the reader turned the page would not be one. --}}
    {{ $metric['name'] }} + {{ Format::leafOf($metric['name'], '.') }} + {{ Format::stemOf($metric['name'], '.') }} + no measurements
    + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) @endif
    @endsection diff --git a/packages/admin/resources/views/oauth2.blade.php b/packages/admin/resources/views/oauth2.blade.php index ab6b9909..2980c1e2 100644 --- a/packages/admin/resources/views/oauth2.blade.php +++ b/packages/admin/resources/views/oauth2.blade.php @@ -3,9 +3,8 @@ @section('body') @php // Whether the Active counts came from a per-process store (the endpoint's own - // `authorizations.processLocal`). Defaulted rather than assumed, so a payload that does not say is - // rendered without a caveat instead of with one nothing substantiates. - $processLocal = ($processLocalAuthorizations ?? false) === true; + // `authorizations.processLocal`), decided in AdminAction and carried here for the note below. + $processLocal = $processLocalAuthorizations; @endphp
    @@ -17,81 +16,60 @@
    @include('firefly-admin::_panel-head', [ - 'title' => 'Clients', 'count' => count($clients), - 'filter' => 'oauth2-body', 'placeholder' => 'Filter by client id, grant or scope…', + 'title' => 'Clients', 'count' => $slice->total, 'query' => $query, + 'placeholder' => 'Search by client id, grant or scope…', ]) - @if ($clients === []) - @include('firefly-admin::_empty', [ - 'title' => 'No clients registered', - 'body' => 'Add a block under firefly.security.oauth2.server.clients, or switch clients.driver to eloquent and register clients dynamically.', - ]) + @if ($slice->isEmpty()) + @include('firefly-admin::_empty', $query->isFiltered() + ? ['title' => 'Nothing matches', 'body' => 'No client id, name, grant or scope contains that. Show them all.'] + : ['title' => 'No clients registered', 'body' => 'Add a block under firefly.security.oauth2.server.clients, or switch clients.driver to eloquent and register clients dynamically.']) @else
    - - +
    ClientAuthenticationGrantsScopesRedirect URIsTokensActive
    + @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) - @foreach ($clients as $client) - @php - // Assembled here rather than with inline @if fragments: Blade only compiles a - // directive that is NOT glued to a word character, so `…}}s@if(…) · PKCE@endif` - // silently leaves an unclosed `if` in the compiled view. - $issuance = [(string) ($client['accessTokenFormat'] ?? ''), ($client['accessTokenTtl'] ?? 0).'s']; - // `requiresProofKey` (what the endpoints enforce), never `requireProofKey` (the - // switch the client registered): `require_pkce` and - // `require_proof_key_for_public_clients` both default to on, so the registered - // switch reads "no PKCE" for a client whose every authorization request is in fact - // refused without a code_challenge — the first thing this page is opened to explain. - // - // Strictly `=== true`, because both keys are `null` for a client with no - // authorization_code grant: PKCE and consent are that path's rules, so a machine - // client sends no code_challenge and reaches no consent screen, and the endpoint - // says "does not apply" rather than reporting a default nothing enforces. A - // truthy test would print both labels beside it and send the operator checking - // two requirements that are not there. - if (($client['requiresProofKey'] ?? null) === true) { - $issuance[] = 'PKCE'; - } - if (($client['requireAuthorizationConsent'] ?? null) === true) { - $issuance[] = 'consent'; - } - $active = is_numeric($client['activeAuthorizations'] ?? null) ? (int) $client['activeAuthorizations'] : 0; - // A `0` counted in a per-process store is the one cell that reads as a fact and is - // not one: the workers beside this one may be holding a hundred live - // authorizations for this client, and an operator who reads "none" goes looking - // for a token endpoint that is refusing nobody. Shown as `—` — nothing counted - // here — while a non-zero count is kept, because that one is a floor the store - // can vouch for. The note under the table names the key that makes it server-wide. - $activeCell = $processLocal && $active === 0 ? '—' : (string) $active; - @endphp + @foreach ($slice->rows as $client) - - - - - - - + {{-- The client name is `class="ns"` WITHOUT `stem`: it is a human name, not a + qualified prefix, so it elides from the right like any prose rather than + from the left like a namespace. --}} + + + + + + + @endforeach
    {{ $client['clientId'] ?? '' }}{{ $client['clientName'] ?? '' }}{{ implode(' ', $client['authenticationMethods'] ?? []) }}{{ implode(' ', $client['grantTypes'] ?? []) }}{{ implode(' ', $client['scopes'] ?? []) ?: '—' }}{{ implode(' ', $client['redirectUris'] ?? []) ?: '—' }}{{ implode(' · ', $issuance) }}{{ $activeCell }} + {{ $client['clientId'] }} + {{ $client['clientName'] }} + {{ $client['authentication'] ?: '—' }}{{ $client['grants'] ?: '—' }}{{ $client['scopes'] ?: '—' }}{{ $client['redirects'] ?: '—' }}{{ $client['issuance'] }}{{ $client['active'] }}
    - @if ($processLocal) - {{-- Said where the number is read, not in the release notes. Phrased as the store this - process RESOLVED rather than as the value of the driver key or the name of a shipped - class, because what the endpoint reports is the store's own processLocal() — an - application may bind a per-process service of its own, and then neither the key nor the - class says anything. --}} -

    Active counts this worker only. The authorization store this - process resolved keeps its authorizations in the process — a map rebuilt in every PHP - worker — so these are the ones held by the worker that rendered this page, and under - php-fpm or Octane the next request lands on a different one. A client with live tokens - elsewhere therefore shows — here. Set - firefly.security.oauth2.server.authorizations.driver to eloquent - (and run the oauth2_authorizations migration), or bind a durable - OAuth2AuthorizationService of your own, for counts that describe the - deployment.

    - @endif + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) + @endif + @if ($processLocal) + {{-- Said where the number is read, not in the release notes. Phrased as the store this + process RESOLVED rather than as the value of the driver key or the name of a shipped + class, because what the endpoint reports is the store's own processLocal() — an + application may bind a per-process service of its own, and then neither the key nor the + class says anything. + + OUTSIDE the @if on the listing, because a narrowed search that matches nothing does not + make the caveat untrue: the page is still counting one worker's authorizations, and a + reader who has just typed a term is exactly the reader about to conclude something from + an Active column they cannot see. --}} +

    Active counts this worker only. The authorization store this + process resolved keeps its authorizations in the process — a map rebuilt in every PHP + worker — so these are the ones held by the worker that rendered this page, and under + php-fpm or Octane the next request lands on a different one. A client with live tokens + elsewhere therefore shows — here. Set + firefly.security.oauth2.server.authorizations.driver to eloquent + (and run the oauth2_authorizations migration), or bind a durable + OAuth2AuthorizationService of your own, for counts that describe the + deployment.

    @endif
    @endsection diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 94752036..07151811 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -426,8 +426,33 @@ private function data(Request $request, string $slug): array return match ($slug) { '' => $this->overview(), 'health' => ['indicators' => $this->reader->healthIndicators(), 'aggregate' => $this->aggregateStatus()], - 'metrics' => ['metrics' => $this->metrics()], - 'http' => ['exchanges' => $this->exchanges()], + 'metrics' => $this->metricsPage($request), + // A LOG IS THE ONE LISTING ON THIS DASHBOARD THAT OPENS ON AN ORDER NOBODY ASKED FOR. Its + // tiebreak is the correlation id, and newest-first is a deliberate choice about the page rather + // than the identity the rows happen to have, so it is declared — and `meaningful()` then keeps + // the pair out of every URL the page writes, which is how /firefly/http stays /firefly/http. + // See listing()'s docblock for why the other listings declare nothing. + 'http' => $this->listing( + $request, + 'http', + $this->exchanges(), + TableView::of( + TableColumn::stamp('timestamp', 'When', ch: 12), + TableColumn::pill('method', 'Method'), + TableColumn::path('path', 'Path', weight: 6), + TableColumn::pill('status', 'Status', ch: 6), + // `duration` IS PRE-FORMATTED — `12.4 ms`, `1.2 s` — so ordering by it would order by + // the leading digit and put `9.1 ms` after `1.2 s`. Same reason the scheduled tasks' + // interval columns are unsortable; the fix is a numeric column, not a sort link. + TableColumn::number('duration', 'Took', ch: 10, sortable: false), + TableColumn::token('correlationId', 'Correlation', weight: 2), + TableColumn::token('traceId', 'Trace', weight: 2), + ), + ['path', 'method', 'status', 'correlationId', 'traceId'], + 'correlationId', + defaultSort: 'timestamp', + defaultDirection: 'desc', + ), 'beans' => $this->listing( $request, 'beans', @@ -479,7 +504,7 @@ private function data(Request $request, string $slug): array ['runnable', 'cron', 'zone'], 'runnable', ), - 'oauth2' => $this->oauth2(), + 'oauth2' => $this->oauth2Page($request), // Shapes verified against the real endpoints: configprops answers {beans: {class => row}} // and caches answers {default: name|null, caches: {name => row}}. 'env' => $this->listing( @@ -1017,7 +1042,7 @@ private function overview(): array 'positive' => $this->subArray($conditions, 'positiveMatches'), 'negative' => $this->subArray($conditions, 'negativeMatches'), 'tasks' => $this->listOf('scheduledtasks', 'tasks'), - 'metrics' => $this->metrics(), + 'metrics' => $this->meterRows(), 'exchanges' => array_slice($this->exchanges(), 0, 8), 'bootMode' => AppScan::cachedFile($this->container, AppScan::ROUTES) !== null ? 'compiled' : 'scanned', 'endpoints' => $this->reader->available(), @@ -1074,15 +1099,71 @@ private function aggregateStatus(): string } /** + * The Metrics page's model: one page of meters, plus the scale the bars are drawn against. + * + * THE SCALE IS A PROPERTY OF THE WHOLE RESULT SET, NOT OF THE SLICE. A bar rescaled per page would say + * something different about the same number depending on which page it was drawn on — the largest meter + * on page 2 would fill its row exactly as the largest meter on page 1 does, though one may be a + * thousand times the other. So `peak` is taken across every meter the endpoint reported, before the + * search and the slice, and a narrowed page still draws its meters against the whole registry. + * + * The rows are read ONCE and used twice. `meterRows()` is not cheap — it reads the metrics index and + * then reads every name back, so it is N+1 in-process endpoint calls — and calling it a second time for + * the peak would double that for a number already in hand. + * + * @return array + */ + private function metricsPage(Request $request): array + { + $meters = $this->meterRows(); + + $peak = 0.0; + foreach ($meters as $meter) { + $peak = max($peak, $meter['peak']); + } + + return [ + ...$this->listing( + $request, + 'metrics', + $meters, + TableView::of( + // Split on `.` the way a class name splits on its namespace separator: + // `http.server.requests` is a leaf under a stem, and the stem is the half that may be + // elided when the column runs out of room. + TableColumn::qualified('name', 'Meter', weight: 5, separator: '.'), + TableColumn::token('statistics', 'Statistic', weight: 2), + TableColumn::number('peak', 'Value', ch: 12), + TableColumn::meter('Relative'), + ), + ['name', 'statistics'], + 'name', + ), + 'peak' => $peak, + ]; + } + + /** + * One row per METER, with its measurements nested. + * + * THE LISTING UNIT IS THE METER. A meter's `count`, `total` and `max` are three readings of one thing, + * and paging by measurement would put `count` on page 3 and the `total` it counts on page 4. So the + * page sorts, searches and slices meters, and each meter draws however many rows it has — which is also + * why `statistics` exists: a searchable, sortable projection of the nested rows. + * + * `peak` is the same projection for ORDER and for the bar: the largest magnitude this meter reported, so + * the Value column has one number per row to sort by rather than a nested list, and metricsPage() can + * take the page-wide scale off the rows it already has. + * * The metrics index returns names only, so each name is read back for its measurements — N in-process * calls, the right trade for a dashboard, and it keeps MetricsEndpoint's contract untouched. * * Each measurement is pre-formatted here (bytes as MB, seconds as ms) because the view must not be * doing arithmetic, and the JSON surface must keep returning raw numbers for Prometheus. * - * @return list}> + * @return list}> */ - private function metrics(): array + private function meterRows(): array { $metrics = []; foreach ($this->subArray($this->payload('metrics'), 'names') as $name) { @@ -1094,6 +1175,7 @@ private function metrics(): array $detail = $this->reader->read('metrics', [$name]) ?? []; $rows = []; + $peak = 0.0; foreach ($this->subArray($detail, 'measurements') as $measurement) { if (! is_array($measurement)) { continue; @@ -1105,9 +1187,17 @@ private function metrics(): array 'value' => is_numeric($value) ? (float) $value : 0.0, 'display' => is_numeric($value) ? Format::measurement($name, (float) $value) : '—', ]; + // ABSOLUTE, because a gauge may be negative and a bar has no sign: the comparison the page + // offers is "which of these is large", and -2 GB of free memory is large. + $peak = max($peak, is_numeric($value) ? abs((float) $value) : 0.0); } - $metrics[] = ['name' => $name, 'rows' => $rows]; + $metrics[] = [ + 'name' => $name, + 'statistics' => implode(' ', array_column($rows, 'statistic')), + 'peak' => $peak, + 'rows' => $rows, + ]; } return $metrics; @@ -1121,7 +1211,7 @@ private function metrics(): array * only the latter pair, which the endpoint never produced, so the page showed an empty path and `—` for * the age of every request it listed. * - * @return list> + * @return list */ private function exchanges(): array { @@ -1148,16 +1238,17 @@ private function exchanges(): array } /** - * The OAuth2 page's model, from ONE read of the `oauth2clients` endpoint. + * The OAuth2 page's model, still from ONE read of the `oauth2clients` endpoint. * * Shape verified against OAuth2ClientsEndpoint: `{issuer: string, authorizations: {processLocal: bool}, - * clients: list}`. The single read is the point. AdminEndpointReader::read() does not memoize — it - * calls handle() again on every call, and re-walks the registry through has() on the way — and this - * endpoint is not a cheap in-memory introspection like `caches` or `configprops`: it counts the - * authorizations alive for every client, which the Eloquent service answers with one query per client. - * Filling the keys with a payload() call each tripled that for nothing, and it also broke the endpoint's - * own "one clock for the whole sweep" guarantee across the rendered page — the issuer and the counts would - * each come from a different sweep. + * clients: list}`. The single read is the point AND IT IS WHY `clientRows()` TAKES THE PAYLOAD + * RATHER THAN READING ITS OWN. AdminEndpointReader::read() does not memoize — it calls handle() again on + * every call, and re-walks the registry through has() on the way — and this endpoint is not a cheap + * in-memory introspection like `caches` or `configprops`: it counts the authorizations alive for every + * client, which the Eloquent service answers with one query per client. Filling the keys with a + * payload() call each tripled that for nothing, and it also broke the endpoint's own "one clock for the + * whole sweep" guarantee across the rendered page — the issuer and the counts would each come from a + * different sweep. AdminOAuth2PageSingleReadTest counts the calls. * * `processLocalAuthorizations` is carried through because the Active column is otherwise a number that * looks server-wide and is not: on the default `memory` driver the counts belong to the worker that @@ -1166,18 +1257,107 @@ private function exchanges(): array * * @return array */ - private function oauth2(): array + private function oauth2Page(Request $request): array { $payload = $this->payload('oauth2clients'); - $issuer = $payload['issuer'] ?? null; + $processLocal = ($this->subArray($payload, 'authorizations')['processLocal'] ?? null) === true; return [ - 'issuer' => is_string($issuer) ? $issuer : '', - 'clients' => $this->subArray($payload, 'clients'), - 'processLocalAuthorizations' => ($this->subArray($payload, 'authorizations')['processLocal'] ?? null) === true, + ...$this->listing( + $request, + 'oauth2', + $this->clientRows($payload, $processLocal), + TableView::of( + // `separator: ''` — the two lines of this cell come from two different fields rather + // than from splitting one, so the view supplies both halves itself. See TableColumn. + TableColumn::qualified('clientId', 'Client', weight: 4, separator: ''), + TableColumn::token('authentication', 'Authentication', weight: 3), + TableColumn::token('grants', 'Grants', weight: 3), + TableColumn::token('scopes', 'Scopes', weight: 3), + TableColumn::line('redirects', 'Redirect URIs', weight: 4), + TableColumn::token('issuance', 'Tokens', weight: 3), + TableColumn::number('active', 'Active', ch: 7), + ), + ['clientId', 'clientName', 'grants', 'scopes'], + 'clientId', + ), + 'issuer' => is_string($payload['issuer'] ?? null) ? $payload['issuer'] : '', + 'processLocalAuthorizations' => $processLocal, ]; } + /** + * The OAuth2 clients as flat, sortable rows. + * + * Every multi-valued field is space-joined here rather than in the view, for the reason `beanRows()` + * joins interfaces: a listing can order and search a string and cannot order a list. The `active` + * column keeps the `—`-for-zero rule exactly — see oauth2Page() for why a zero counted in a per-process + * store is the one cell that reads as a fact and is not one. + * + * TAKES THE PAYLOAD IT WAS READ FROM, and does not read its own: this endpoint counts authorizations + * per client and a second read is a second sweep of the store. The processLocal flag rides in beside it + * for the same reason. + * + * `requiresProofKey` (what the endpoints enforce), never `requireProofKey` (the switch the client + * registered): `require_pkce` and `require_proof_key_for_public_clients` both default to on, so the + * registered switch reads "no PKCE" for a client whose every authorization request is in fact refused + * without a code_challenge — the first thing this page is opened to explain. Strictly `=== true`, + * because both keys are `null` for a client with no authorization_code grant: PKCE and consent are that + * path's rules, so a machine client sends no code_challenge and reaches no consent screen, and the + * endpoint says "does not apply" rather than reporting a default nothing enforces. A truthy test would + * print both labels beside it and send the operator checking two requirements that are not there. + * + * @param array $payload + * @return list + */ + private function clientRows(array $payload, bool $processLocal): array + { + $rows = []; + foreach ($this->subArray($payload, 'clients') as $client) { + if (! is_array($client)) { + continue; + } + + $issuance = [$this->scalar($client['accessTokenFormat'] ?? ''), $this->scalar($client['accessTokenTtl'] ?? 0).'s']; + if (($client['requiresProofKey'] ?? null) === true) { + $issuance[] = 'PKCE'; + } + if (($client['requireAuthorizationConsent'] ?? null) === true) { + $issuance[] = 'consent'; + } + + $active = is_numeric($client['activeAuthorizations'] ?? null) ? (int) $client['activeAuthorizations'] : 0; + + $rows[] = [ + 'clientId' => $this->scalar($client['clientId'] ?? ''), + 'clientName' => $this->scalar($client['clientName'] ?? ''), + 'authentication' => $this->joined($client['authenticationMethods'] ?? null), + 'grants' => $this->joined($client['grantTypes'] ?? null), + 'scopes' => $this->joined($client['scopes'] ?? null), + 'redirects' => $this->joined($client['redirectUris'] ?? null), + 'issuance' => implode(' · ', $issuance), + // A `0` counted in a per-process store is the one cell that reads as a fact and is not one: + // the workers beside this one may be holding a hundred live authorizations for this client, + // and an operator who reads "none" goes looking for a token endpoint that is refusing + // nobody. Shown as `—` — nothing counted here — while a non-zero count is kept, because + // that one is a floor the store can vouch for. + 'active' => $processLocal && $active === 0 ? '—' : (string) $active, + ]; + } + + return $rows; + } + + /** A multi-valued endpoint field as one searchable, orderable string. */ + private function joined(mixed $values): string + { + if (! is_array($values)) { + return ''; + } + + return implode(' ', array_map(fn (mixed $value): string => $this->scalar($value), $values)); + } + /** * The endpoint's `timestamp` is ISO-8601 UTC with microseconds (HttpExchange::timestampFrom()); the page * wants seconds since the epoch for Format::since(). A numeric value is accepted too, so a row from an diff --git a/packages/admin/tests/AdminOAuth2PageProcessLocalTest.php b/packages/admin/tests/AdminOAuth2PageProcessLocalTest.php index c74aa505..e4025dd3 100644 --- a/packages/admin/tests/AdminOAuth2PageProcessLocalTest.php +++ b/packages/admin/tests/AdminOAuth2PageProcessLocalTest.php @@ -71,8 +71,8 @@ private static function row(string $clientId, int $active): array ->assertOk() ->assertSee('Active counts this worker only') ->assertSee('authorizations.driver') - ->assertSee('
    —2—20202
    ') + ->toContain('class="t-meter"') + ->not->toContain('style="width:22%"'); + + // The two measurements of one meter, in one , with the meter named once and its second row + // carrying an empty name cell — the shape that says "these belong together". + preg_match('#]*>(.*?)#s', $body, $rows); + expect($rows[1] ?? '')->toContain('COUNT')->toContain('TOTAL_TIME') + // A meter the registry holds under a name with no measurements is legal, and the view has an arm + // for it rather than a missing row. + ->toContain('no measurements'); +}); + +/** + * THE HTTP LISTING IS THE ONE THAT OPENS ON AN ORDER NOBODY ASKED FOR, and is allowed to. + * + * Every other listing on this dashboard opens in its tiebreak's order and claims nothing (see "claims no + * ordering on a configuration listing the reader has not ordered"). This one's tiebreak is the correlation + * id and its opening order is newest-first — a deliberate choice about a log, not an identity — so it + * declares `timestamp desc` and `meaningful()` keeps that pair out of every URL it writes: the landing page + * is `/firefly/http`, and the When header's own link degrades to a bare `?dir=asc`. + * + * The Method link carries `dir=asc` EXPLICITLY, and that is not noise: this listing's declared default + * direction is `desc`, so ascending is the half of the pair that differs from the default and has to be + * written down. A link that omitted it would sort by method descending. + */ +it('opens the HTTP traffic newest first, and says so without putting it in the URL', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/http') + ->assertStatus(200) + ->assertSee('href="/firefly/http?sort=method&dir=asc"', false) + // The default sort is elided from every link, so the landing URL stays /firefly/http and the When + // column's own header link asks only for the other direction. + ->assertSee('href="/firefly/http?dir=asc"', false) + ->assertDontSee('href="/firefly/http?sort=timestamp&dir=desc"', false); +}); + +it('flips the HTTP sort to oldest first when the reader asks', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/http?dir=asc')->assertStatus(200)->getContent(); + + expect($body)->toContain('↑'); + + // Oldest first really is oldest first: the 500 the fixture recorded two hours ago leads, and the 200 it + // recorded two seconds ago is behind it. + preg_match('#]*>(.*?)#s', $body, $rows); + expect(strpos($rows[1] ?? '', 'code err'))->toBeInt() + ->toBeLessThan((int) strpos($rows[1] ?? '', 'code ok')); +}); + +it('pages the OAuth2 clients and keeps the process-local caveat', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/oauth2') + ->assertStatus(200) + ->assertSee('
    ', false) + ->assertSee('href="/firefly/oauth2?sort=clientId"', false) + // The client id over the client name is a two-line cell like a class name — but WITHOUT `stem`: + // a human name is prose and elides from the right, not from the left. + ->assertSee('Storefront', false) + ->assertDontSee('Storefront', false) + // The Active column's `—` for a zero no per-process store can vouch for, and the paragraph naming + // the key that makes the column server-wide, both survive the move onto the listing engine. + ->assertSee('', false) + ->assertSee('Active counts this worker only'); +}); + +/** + * THE THREE RUNTIME PAGES ANSWER "WHAT IS THIS SORTED BY?" THE WAY THE REST OF THE DASHBOARD DOES. + * + * Metrics and OAuth2 clients open in their tiebreak's order — the meter name, the client id — so they + * claim nothing: every header link names its own column and no arrow is drawn until a reader asks. HTTP + * traffic is the single exception on this dashboard and it is asserted above. Getting this wrong is not a + * broken page, which is exactly why it needs pinning: it is one affordance quietly meaning two things + * depending on which page you are on. + */ +it('claims no ordering on the runtime listings the reader has not ordered', function () { + /** @var AdminTableCapstoneTestCase $this */ + $opening = [ + '/firefly/metrics' => 'Meter', + '/firefly/oauth2' => 'Client', + ]; + + foreach ($opening as $url => $header) { + $body = (string) $this->get($url)->assertStatus(200)->getContent(); + + expect($body)->toContain($header)->not->toContain('↑'); + } +}); + +/** + * The Relative bar is a picture of the WHOLE result set, not of the slice the reader is looking at. + * + * A bar rescaled per page would say something different about the same number depending on which page it + * was drawn on — 100% on page 2 because page 2's largest meter is small. The scale is therefore the peak + * across every meter the endpoint reported, computed once and handed to the view, which is why the page's + * widest value renders as a full bar even when the listing is narrowed to something smaller. + */ +it('scales the metrics bar against every meter, not against the page being drawn', function () { + /** @var AdminTableCapstoneTestCase $this */ + $narrowed = (string) $this->get('/firefly/metrics?q=cache')->assertStatus(200)->getContent(); + + // `firefly.cache.hits` is 512 against a fixture peak of 2 097 152, so on its own page it is a sliver + // rather than the 100% a per-slice scale would give it. + expect($narrowed)->toContain('') + ->not->toContain(''); +}); diff --git a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php index 37ff0da1..f9e6e438 100644 --- a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php +++ b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php @@ -4,8 +4,12 @@ namespace Firefly\Admin\Tests\Support; +use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Admin\Tests\Support\Fixtures\BillingProperties; +use Firefly\Admin\Tests\Support\Fixtures\HttpExchangesEndpointStub; use Firefly\Admin\Tests\Support\Fixtures\LedgerProperties; +use Firefly\Admin\Tests\Support\Fixtures\MetricsEndpointStub; +use Firefly\Admin\Tests\Support\Fixtures\OAuth2ClientsEndpointStub; use Firefly\Config\Scanner\ConfigPropertiesDescriptor; use Firefly\Config\Scanner\ConfigPropertiesManifest; use Firefly\Context\Condition\ConditionEvaluationReport; @@ -60,6 +64,14 @@ * supplies one) is honoured rather than ignored". The BOUND DTO is then bound as an instance, because * `describe()` reads the resolved object back out of the container — that IS the endpoint's contract — and * the profile-gated one is deliberately left unbound, which is the state ConfigRegistrar leaves it in. + * + * AND THE THREE RUNTIME LISTINGS ARE SEEDED BY REGISTERING THEIR ENDPOINTS, because a testbench that boots + * no observability stack and no authorization server does not have them at all: `/firefly/metrics`, + * `/firefly/http` and `/firefly/oauth2` answer 404 through AdminPage::requires rather than rendering an + * empty listing. The stubs go into the REAL ActuatorRegistry after boot — the same seam + * AdminOAuth2PageProcessLocalTest uses — so the reader resolves them, AdminAction reads them and the views + * render exactly as they would over the shipped endpoints; what is stubbed is the meter registry, the + * exchange ring and the client store behind them, none of which a single-process test can fill honestly. */ abstract class AdminTableCapstoneTestCase extends AdminCapstoneTestCase { @@ -84,6 +96,11 @@ protected function setUp(): void foreach ($this->backedOff() as [$class, $attribute, $reason]) { $report->record($class, $attribute, ConditionOutcome::noMatch($reason)); } + + $registry = $this->app()->make(ActuatorRegistry::class); + $registry->register(new MetricsEndpointStub($this->meters())); + $registry->register(new HttpExchangesEndpointStub($this->exchanges())); + $registry->register(new OAuth2ClientsEndpointStub($this->clients())); } /** @@ -154,6 +171,111 @@ protected function boundConfigProperties(): array return [BillingProperties::class => new BillingProperties]; } + /** + * The meters the Metrics page lists, as the registry would report them. + * + * FOUR NAMES AND SIX MEASUREMENTS, because the listing unit on that page is the METER and not the + * measurement: `http.server.requests` carries a count and a total time that are two readings of one + * thing, and a page that sliced by measurement would put the count on one page and the total it counts + * on the next. Four meters over six rows is the smallest fixture in which those two numbers can be + * observed to travel together. + * + * `php.memory.used.bytes` and `http.server.requests.duration` are here for their SUFFIXES: Format + * infers the unit from the meter name, so those two render as `2.0 MB` and a duration while the plain + * counters render as counts — the Value column is not one alphabet, which is exactly why the Relative + * bar beside it is the only comparison the page offers. `firefly.mail.sent` has no measurements at all, + * which is legal (the endpoint answers per NAME and several meters may share one) and is the view's + * `@empty` arm. + * + * @return array> + */ + protected function meters(): array + { + return [ + 'http.server.requests' => [ + ['statistic' => 'COUNT', 'value' => 128.0], + ['statistic' => 'TOTAL_TIME', 'value' => 3.5], + ], + 'http.server.requests.duration' => [['statistic' => 'TOTAL_TIME', 'value' => 0.042]], + 'firefly.cache.hits' => [['statistic' => 'COUNT', 'value' => 512.0]], + 'php.memory.used.bytes' => [['statistic' => 'VALUE', 'value' => 2097152.0]], + 'firefly.mail.sent' => [], + ]; + } + + /** + * The ring the HTTP traffic page lists, newest first and in the endpoint's own row shape. + * + * The ages are RELATIVE to the moment the case boots, because the When column renders through + * Format::since: a fixed instant would drift past its 24-hour boundary and start rendering as an ISO + * date instead of an age, which is a fixture that changes its own meaning with the calendar. The four + * rows cover the three status bands the view colours (`ok`, `warn`, `err`) and give the Method column + * something to order by other than its own default. + * + * @return list> + */ + protected function exchanges(): array + { + $now = microtime(true); + $at = static fn (float $secondsAgo): string => gmdate('Y-m-d\TH:i:s', (int) ($now - $secondsAgo)).'.000000Z'; + + return [ + ['timestamp' => $at(2), 'method' => 'GET', 'uri' => '/orders/{id}', 'status' => 200, 'durationMs' => 12.4, + 'correlationId' => '0a9f4c1e-7c1c-4d0b-9f3a-2b6d5e8a1c77', 'traceId' => '4bf92f3577b34da6a3ce929d0e0e4736'], + ['timestamp' => $at(45), 'method' => 'DELETE', 'uri' => '/orders/{id}', 'status' => 204, 'durationMs' => 8.1, + 'correlationId' => '1b8e3d2f-6a5b-4c9d-8e7f-3c4d5e6f7a88', 'traceId' => '5cf03f4688c45eb7b4df03ae1f1f5847'], + ['timestamp' => $at(300), 'method' => 'GET', 'uri' => '/greetings/{name}', 'status' => 404, 'durationMs' => 3.2, + 'correlationId' => '2c7d4e3a-5b6c-4d8e-9f0a-4d5e6f7a8b99', 'traceId' => '6df14f5799d56fc8c5ef14bf2f2f6958'], + ['timestamp' => $at(7200), 'method' => 'POST', 'uri' => '/orders', 'status' => 500, 'durationMs' => 91.7, + 'correlationId' => '3d6e5f4b-4c7d-4e9f-8a1b-5e6f7a8b9c00', 'traceId' => '7ef25f68aae67fd9d6ff25cf3f3f7a69'], + ]; + } + + /** + * The registered OAuth2 clients, in OAuth2ClientsEndpoint's row shape. + * + * Three of them, one per authentication method the page has ever had to draw, and one — `storefront` — + * with a zero active count, which on a process-local store is the cell the page renders as `—` rather + * than as the fact it is not. Both switches are published on every row (`requireProofKey`, what the + * client registered, and `requiresProofKey`, what the endpoints enforce) so that a page rendering the + * wrong one is visible here rather than only in AdminOAuth2PageSingleReadTest. + * + * @return list> + */ + protected function clients(): array + { + return [ + $this->client('storefront', 'Storefront', ['client_secret_basic'], ['authorization_code', 'refresh_token'], 0), + $this->client('reporting', 'Reporting exports', ['client_secret_post'], ['client_credentials'], 2), + $this->client('mobile-app', 'Mobile application', ['none'], ['authorization_code'], 7), + ]; + } + + /** + * @param list $authentication + * @param list $grants + * @return array + */ + private function client(string $clientId, string $clientName, array $authentication, array $grants, int $active): array + { + return [ + 'id' => $clientId, + 'clientId' => $clientId, + 'clientName' => $clientName, + 'authenticationMethods' => $authentication, + 'grantTypes' => $grants, + 'scopes' => ['openid', 'profile'], + 'redirectUris' => ['https://'.$clientId.'.test/callback'], + 'postLogoutRedirectUris' => [], + 'requireProofKey' => false, + 'requiresProofKey' => true, + 'requireAuthorizationConsent' => $authentication === ['none'], + 'accessTokenFormat' => 'self_contained', + 'accessTokenTtl' => 300, + 'activeAuthorizations' => $active, + ]; + } + /** * Conditions that did NOT match, so the Conditions page has two panels worth listing rather than one. * diff --git a/packages/admin/tests/Support/Fixtures/HttpExchangesEndpointStub.php b/packages/admin/tests/Support/Fixtures/HttpExchangesEndpointStub.php new file mode 100644 index 00000000..88ee587a --- /dev/null +++ b/packages/admin/tests/Support/Fixtures/HttpExchangesEndpointStub.php @@ -0,0 +1,52 @@ +> $exchanges newest first, as the recorder returns them + */ + public function __construct(private array $exchanges) {} + + public function endpointId(): string + { + return 'httpexchanges'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): EndpointResponse + { + return EndpointResponse::json([ + 'recording' => true, + 'storage' => 'memory', + 'processLocal' => true, + 'recorded' => count($this->exchanges), + 'count' => count($this->exchanges), + 'exchanges' => $this->exchanges, + ]); + } +} diff --git a/packages/admin/tests/Support/Fixtures/MetricsEndpointStub.php b/packages/admin/tests/Support/Fixtures/MetricsEndpointStub.php new file mode 100644 index 00000000..e2c8e81a --- /dev/null +++ b/packages/admin/tests/Support/Fixtures/MetricsEndpointStub.php @@ -0,0 +1,64 @@ +> $meters meter name => measurements + */ + public function __construct(private array $meters) {} + + public function endpointId(): string + { + return 'metrics'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): ?EndpointResponse + { + if ($request->subPath === []) { + $names = array_keys($this->meters); + sort($names); + + return EndpointResponse::json(['names' => $names]); + } + + $name = $request->subPath[0]; + if (! array_key_exists($name, $this->meters)) { + return null; + } + + return EndpointResponse::json([ + 'name' => $name, + 'measurements' => $this->meters[$name], + 'availableTags' => [], + ]); + } +} diff --git a/packages/admin/tests/Support/Fixtures/OAuth2ClientsEndpointStub.php b/packages/admin/tests/Support/Fixtures/OAuth2ClientsEndpointStub.php new file mode 100644 index 00000000..6549fb1a --- /dev/null +++ b/packages/admin/tests/Support/Fixtures/OAuth2ClientsEndpointStub.php @@ -0,0 +1,54 @@ +> $clients + */ + public function __construct( + private array $clients, + private string $issuer = 'https://issuer.test', + private bool $processLocal = true, + ) {} + + public function endpointId(): string + { + return 'oauth2clients'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): EndpointResponse + { + return EndpointResponse::json([ + 'issuer' => $this->issuer, + 'authorizations' => ['processLocal' => $this->processLocal], + 'clients' => $this->clients, + ]); + } +} diff --git a/packages/security-oauth2-server/tests/Admin/AdminOAuth2PageFlowTest.php b/packages/security-oauth2-server/tests/Admin/AdminOAuth2PageFlowTest.php index d4485819..da4dc07a 100644 --- a/packages/security-oauth2-server/tests/Admin/AdminOAuth2PageFlowTest.php +++ b/packages/security-oauth2-server/tests/Admin/AdminOAuth2PageFlowTest.php @@ -60,25 +60,25 @@ protected function defineFireflyEnvironment(Application $app): void ->assertSee('orders:read') ->assertSee('client.create') // The one client-credentials token issued above is the one live authorization the svc row reports. - ->assertSee('', false) + ->assertSee('', false) // …counted in the `memory` driver's map, which belongs to the process that rendered this page. The // page says so, and prints `—` rather than `0` for the two clients this worker holds nothing for: // under php-fpm that zero would be every client's, whatever the pool is actually holding. ->assertSee('Active counts this worker only') - ->assertSee('', false) + ->assertSee('', false) // PKCE is reported as the endpoints ENFORCE it, not as the client registered it: public-spa carries // no `require_pkce` of its own, yet the stock server-wide default refuses its every authorization // request without a code_challenge. Its issuance cell is the one without `consent`, so this string // belongs to that row alone. - ->assertSee('', false) + ->assertSee('', false) // web-app holds the authorization_code grant and the stock consent default, so it carries both. - ->assertSee('', false) + ->assertSee('', false) // …and svc carries NEITHER. It is registered for client_credentials only: it never sends a // code_challenge and never reaches a consent screen, so the two rules do not describe it. Both // defaults are on, so a cell built from them would read `· PKCE · consent` beside a machine client // and hand the operator two requirements to check that nothing in the server enforces for it — on // the page opened to answer why a client cannot get a token. - ->assertSee('', false) + ->assertSee('', false) ->assertDontSee(OAuth2ServerCapstoneTestCase::WEB_APP_SECRET) ->assertDontSee(OAuth2ServerCapstoneTestCase::SVC_SECRET); diff --git a/tests/Browser/ObservabilityTest.php b/tests/Browser/ObservabilityTest.php index b22308ac..a7c099e8 100644 --- a/tests/Browser/ObservabilityTest.php +++ b/tests/Browser/ObservabilityTest.php @@ -26,9 +26,16 @@ ->assertSee('/orders/{id}') ->assertDontSee('No exchanges recorded') // The Trace column carries the id in `title`; a row recorded without one renders `title=""`. + // + // READ OFF THE LAST CELL OF EACH ROW, which is the Trace column. It used to be read off every + // `td[title]` in the body, and that was only ever a way of SAYING "the Trace column": since the + // listing wave, the Path and Correlation cells clip and carry their own full value on `title`, the + // way every clipped cell in the table vocabulary does. The claim here is unchanged — every trace + // cell holds a 32-character hex id, and at least two rows have one — it is just aimed at the + // column it was always about. ->assertSourceMissing('title=""') - ->assertScript("Array.from(document.querySelectorAll('#http-body td[title]')).every(function (td) { return /^[0-9a-f]{32}$/.test(td.getAttribute('title')); })", true) - ->assertScript("document.querySelectorAll('#http-body td[title]').length >= 2", true) + ->assertScript("Array.from(document.querySelectorAll('#http-body tr > td:last-child[title]')).every(function (td) { return /^[0-9a-f]{32}$/.test(td.getAttribute('title')); })", true) + ->assertScript("document.querySelectorAll('#http-body tr > td:last-child[title]').length >= 2", true) ->assertNoJavaScriptErrors() ->screenshot(filename: 'observability-http-traffic'); }); From 518b468c7006d26ac53d278b03c2bf5591eeffff Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:06:22 -0700 Subject: [PATCH 40/78] fix(admin): let the OAuth2 Active column order by its counts instead of by the em-dash in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `0` the process-local authorization store cannot vouch for was written into the row as the em-dash itself. `active` is a sortable number column, and `'—'` is neither `null` nor `''`, so RowComparator::rankEmpty() never fired on it and RowComparator::forColumn() met a non-empty, non-numeric value on its first pass and committed the whole column to strnatcasecmp — counts ordered by their leading digit and, an em-dash being three bytes above every digit, a descending Active opening on the page of em-dashes RowComparator exists to prevent. The row now carries the empty string the view draws as `—`, which is the dashboard's own convention for an absent cell (AdminAction::scheduledRows(), http.blade.php, oauth2.blade.php's own scopes and redirects) and the only spelling of one that orders: emptiness is ranked out before the direction is applied, so the unvouchable rows sit at the end of the listing both ways round and the counts keep their arithmetic comparison. A case in AdminTableListingTest pins ?sort=active in both directions. --- .../admin/resources/views/oauth2.blade.php | 7 +++- packages/admin/src/Web/AdminAction.php | 25 ++++++++++---- .../admin/tests/AdminTableListingTest.php | 33 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/admin/resources/views/oauth2.blade.php b/packages/admin/resources/views/oauth2.blade.php index 2980c1e2..9a70f3f9 100644 --- a/packages/admin/resources/views/oauth2.blade.php +++ b/packages/admin/resources/views/oauth2.blade.php @@ -42,7 +42,12 @@ - + {{-- The `—` for a count no per-process store can vouch for is DRAWN HERE, over + the empty string AdminAction leaves in the row, exactly like the absent + cells above it. The row keeps a number so the column can be ordered as + one; see AdminAction::clientRows() for the page of em-dashes an em-dash in + the row would open a descending Active on. --}} + @endforeach diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 07151811..f2b4c595 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -1291,8 +1291,19 @@ private function oauth2Page(Request $request): array * * Every multi-valued field is space-joined here rather than in the view, for the reason `beanRows()` * joins interfaces: a listing can order and search a string and cannot order a list. The `active` - * column keeps the `—`-for-zero rule exactly — see oauth2Page() for why a zero counted in a per-process - * store is the one cell that reads as a fact and is not one. + * column keeps the `—`-for-zero rule exactly (see oauth2Page() for why a zero counted in a per-process + * store is the one cell that reads as a fact and is not one). + * + * IT KEEPS IT AS THE EMPTY STRING THE VIEW DRAWS AS `—`, NEVER AS THE EM-DASH ITSELF, and that is not a + * stylistic preference: `active` is a sortable number column, and the empty string is the only spelling + * of an absent cell that orders. `Table\InMemoryListing` ranks `null` and `''` out through + * `RowComparator::rankEmpty()` BEFORE the direction is applied, so an unvouchable count sits at the end + * of the listing whichever way the reader runs it. A literal `'—'` is neither, so it would ride through + * as an ordinary value AND — being non-numeric — commit the whole column to `strnatcasecmp` through + * `RowComparator::forColumn()`: live counts ordered by their leading digit, and, an em-dash being three + * bytes above every digit, a descending Active opening on a page of em-dashes. That is precisely the + * failure RowComparator's docblock is written around, and the one the `Took` column on the HTTP page + * sidesteps by declining to sort at all. * * TAKES THE PAYLOAD IT WAS READ FROM, and does not read its own: this endpoint counts authorizations * per client and a second read is a second sweep of the store. The processLocal flag rides in beside it @@ -1308,7 +1319,7 @@ private function oauth2Page(Request $request): array * print both labels beside it and send the operator checking two requirements that are not there. * * @param array $payload - * @return list + * @return list */ private function clientRows(array $payload, bool $processLocal): array { @@ -1339,9 +1350,11 @@ private function clientRows(array $payload, bool $processLocal): array // A `0` counted in a per-process store is the one cell that reads as a fact and is not one: // the workers beside this one may be holding a hundred live authorizations for this client, // and an operator who reads "none" goes looking for a token endpoint that is refusing - // nobody. Shown as `—` — nothing counted here — while a non-zero count is kept, because - // that one is a floor the store can vouch for. - 'active' => $processLocal && $active === 0 ? '—' : (string) $active, + // nobody. Emptied — nothing counted here, and the view draws the `—` — while a non-zero + // count is kept as the NUMBER it is, because that one is a floor the store can vouch for + // and the column is sortable. See this method's docblock for why the em-dash cannot live + // in the row. + 'active' => $processLocal && $active === 0 ? '' : $active, ]; } diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index 8ea3e57f..7ca52483 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -519,6 +519,39 @@ ->assertSee('Active counts this worker only'); }); +/** + * THE ACTIVE COLUMN ORDERS BY ITS NUMBERS, AND THE COUNTS NO STORE CAN VOUCH FOR SIT LAST IN BOTH + * DIRECTIONS. + * + * That is the whole reason the em-dash in that column is drawn by the VIEW over an empty cell rather than + * carried in the row. A row value of `'—'` is neither `null` nor `''`, so `RowComparator::rankEmpty()` + * never fires on it; `RowComparator::forColumn()` then meets a non-empty, non-numeric value on its first + * pass and commits the whole column to `strnatcasecmp`, which orders counts by their leading digit — and + * because an em-dash is three bytes above every digit, a descending Active opens on exactly the page of + * em-dashes RowComparator exists to prevent: the clients with nothing to show pushed in front of the + * counts the operator asked to see. It is the same trap the Took column on /firefly/http is kept out of by + * declining to sort at all. + */ +it('orders the Active column by its counts and parks the ones no store can vouch for last', function () { + /** @var AdminTableCapstoneTestCase $this */ + $leaders = ['desc' => ['mobile-app', 'reporting'], 'asc' => ['reporting', 'mobile-app']]; + + foreach ($leaders as $direction => [$first, $second]) { + $body = (string) $this->get('/firefly/oauth2?sort=active&dir='.$direction)->assertStatus(200)->getContent(); + + preg_match('#]*>(.*?)#s', $body, $rows); + $tbody = $rows[1] ?? ''; + $at = static fn (string $clientId): int => (int) strpos($tbody, ''.$clientId.''); + + // 7 over 2 descending, 2 over 7 ascending — arithmetic, not the text of the cell. + expect($at($first))->toBeGreaterThan(0) + ->and($at($first))->toBeLessThan($at($second)) + // `storefront`'s 0 is the one a per-process store cannot vouch for: behind both of them either + // way round, never leading the page the reader asked to open on the busiest clients. + ->and($at($second))->toBeLessThan($at('storefront')); + } +}); + /** * THE THREE RUNTIME PAGES ANSWER "WHAT IS THIS SORTED BY?" THE WAY THE REST OF THE DASHBOARD DOES. * From f67a9ddaafa4ca0565c33754d667c381ec056d65 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:10:00 -0700 Subject: [PATCH 41/78] fix(web): re-issue the request under its front controller, and build both 405 sentences in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Try again" was built from `ErrorReport::$path`, which is Laravel's `path()` and therefore Symfony's `getPathInfo()` — base-URL-stripped by design, because the router matches on the path info and the front controller is not part of what was asked for. On a deployment served under a base path that made the PRIMARY action on every 5xx page a URL the deployment never serves: `/app/index.php/orders/42` rendered as `/orders/42`, which 404s or leaves the application, during the one incident in which a reader clicks it. The report now carries `getBaseUrl()` beside `path` and `query`, and retry() prepends it — the spelling LoginPageAction, AuthorizationEndpoint, OAuth2LoginPageLinks and FakeAuthorizationServer already share — and puts the CONCATENATION through ErrorPageSettings::url() rather than its tail, so the `/\evil.example` refusal holds for the href a browser actually reads. The fact grid still states `path` alone. The 405 also had two builders for one list of verbs: ErrorPage::methodSentence() wrote "That address does not accept a GET request. It accepts POST." while ProblemMapper::methodNotAllowed() put "This address only accepts POST." on the wire for the same exception, and lede() claimed in its own headline that the page and the problem document agree. Both sentences come out of ProblemMapper::methodSentence() now, one argument apart: the document is built from the throwable alone and has no request to read the refused verb off, so that clause is the whole of the difference and it is visible in one ternary instead of two files. The divergence is stated in lede()'s opening paragraph and pinned against the document the way the 5xx lede is pinned against OPAQUE_WITH_REFERENCE, so neither wording can be re-decided alone. --- packages/web/src/Error/ErrorPage.php | 67 ++++++++------ packages/web/src/Error/ErrorPageSettings.php | 3 +- packages/web/src/Error/ErrorReport.php | 20 ++++ packages/web/src/Error/ProblemMapper.php | 62 +++++++++++-- packages/web/tests/Error/ErrorPageTest.php | 96 ++++++++++++++++++++ skeleton/config/firefly.php | 6 +- 6 files changed, 213 insertions(+), 41 deletions(-) diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php index f81035bc..394cf135 100644 --- a/packages/web/src/Error/ErrorPage.php +++ b/packages/web/src/Error/ErrorPage.php @@ -76,7 +76,8 @@ private static function header(ErrorReport $report, ErrorPageSettings $settings) } /** - * The one sentence a production page says, chosen so that the page and the problem document agree. + * The one sentence a production page says, chosen so that the page and the problem document agree — + * with one declared exception, which is the 405 and is the last paragraph here. * * THREE SOURCES, MOST SPECIFIC FIRST. A 405 the ROUTER raised knows something no exception message does * — which verb the caller used — so it gets a sentence built from both. Then the AUTHORED sentence: @@ -88,18 +89,30 @@ private static function header(ErrorReport $report, ErrorPageSettings $settings) * * THE 405 BRANCH SITS ABOVE THE `authored-detail` GATE, AND IT IS NOT GOVERNED BY IT — which is worth * stating because the ORDER is what decides it. That key exists to say whether the sentence an - * APPLICATION wrote may reach a person. Nothing of the application's is in this one: ProblemMapper read - * the verbs off the `Allow` header the ROUTER put on its own exception, and methodSentence() below - * writes the words. So there is nothing for the key to withhold, and turning it off to get the + * APPLICATION wrote may reach a person. Nothing of the application's is in this one: the verbs come off + * the `Allow` header the ROUTER put on its own exception, and the words are written by + * ProblemMapper::methodSentence(), which is the framework's own prose for this page and for the + * document alike. So there is nothing for the key to withhold, and turning it off to get the * status-and-code page leaves this sentence exactly where it was — the page saying about a 405 what the * document beside it already says in its `allowed` member. ErrorPageSettings and * skeleton/config/firefly.php state that where an operator reads about the key, and ErrorPageTest pins * it, because an undocumented exception to a documented key is the same bug as a wrong default. + * + * AND IT IS THE ONE PLACE THE TWO SURFACES DO NOT SAY THE SAME WORDS, which is stated here rather than + * left for a reader to discover, because the headline above would otherwise be read literally. The page + * says "That address does not accept a GET request. It accepts POST." where the document says "This + * address only accepts POST.", and the difference is a clause the document cannot write: it is built + * from the throwable alone and has no request to read the refused verb off. What the two DO share is + * the verb list and the prose that joins it, because both sentences come out of one builder — + * ProblemMapper::methodSentence(), which this branch calls with the request's method and + * methodNotAllowed() calls without one. ErrorPageTest pins the pair against each other, the same way it + * pins the 5xx lede against ProblemMapper::OPAQUE_WITH_REFERENCE, so neither wording can be re-decided + * on its own. */ private static function lede(ErrorReport $report, ErrorPageSettings $settings): string { if ($report->status === 405 && $report->allowed !== []) { - return self::methodSentence($report->method, $report->allowed); + return ProblemMapper::methodSentence($report->allowed, $report->method); } $authored = $settings->authoredDetail ? self::authoredSentence($report) : ''; @@ -142,26 +155,6 @@ private static function authoredSentence(ErrorReport $report): string return $report->publicDetail === ProblemMapper::statusText($report->status) ? '' : $report->publicDetail; } - /** - * A 405 in the product's words, naming the verb that was refused and the ones that are not. - * - * The verbs were already in hand: ProblemMapper parses the router's Allow header, drops HEAD (Symfony - * adds it beside every GET and no person chooses it) and publishes the rest as an `allowed` extension - * member — and the page threw them away and shrugged. Written with "does not" rather than a contraction - * because that is this page's voice ("That page does not exist.", "You do not have access to that.") and - * because an apostrophe here would reach the markup as `'`. - * - * @param list $allowed - */ - private static function methodSentence(string $method, array $allowed): string - { - $verbs = count($allowed) === 1 - ? $allowed[0] - : implode(', ', array_slice($allowed, 0, -1)).' or '.$allowed[count($allowed) - 1]; - - return "That address does not accept a {$method} request. It accepts {$verbs}."; - } - /** * What a reader can do next — and nothing this deployment did not configure. * @@ -256,6 +249,24 @@ private static function actions(ErrorReport $report, ErrorPageSettings $settings * link's whole promise is that it is the same one. Anything else, and actions() makes no offer: a link * the page cannot spell truthfully is worse than a row with one fewer button on it. * + * AND THE FRONT CONTROLLER'S OWN PREFIX IS PART OF THE ADDRESS, which the second spelling of this + * method also dropped. `$report->path` is Laravel's `path()`, which is Symfony's `getPathInfo()` and is + * base-URL-STRIPPED by design: a request for /app/index.php/orders/42 answers `orders/42`, because the + * router matches on the path info and the front controller is not part of what was asked for. Prefixed + * with a slash that is `/orders/42` — a URL the deployment does not serve, so on every box served under + * a base path the PRIMARY action on every 5xx page 404s or leaves the application entirely. The family + * has one settled spelling for this and it is `$request->getBaseUrl().$path`, which LoginPageAction, + * AuthorizationEndpoint, OAuth2LoginPageLinks and FakeAuthorizationServer all use and which + * `it keeps the base path a front controller is served under` pins in firefly/security. The value rides + * on the report as `ErrorReport::$baseUrl` and is '' for the ordinary rewrite-to-the-root deployment, + * where the concatenation is the path unchanged. + * + * THE GUARD SEES THE WHOLE HREF, not its tail. `getBaseUrl()` is raw — it is a prefix of REQUEST_URI + * matched against SCRIPT_NAME, not a value this package composed — so checking `$report->path` and then + * concatenating something in FRONT of it would be asking the question about a string that is not the + * one printed. The concatenation is built first and `ErrorPageSettings::url()` is asked about that, so + * the `/\evil.example` refusal above holds for the href a browser will actually read. + * * THE QUERY IS SAFE ON ITS OWN TERMS. It is Symfony's `getQueryString()`, which percent-encodes to * RFC 3986 — `"` is already `%22` * and `<` is `%3C` before this page escapes anything — so it cannot end the attribute, cannot introduce @@ -267,11 +278,13 @@ private static function actions(ErrorReport $report, ErrorPageSettings $settings */ private static function retry(ErrorReport $report): string { - if (ErrorPageSettings::url($report->path) !== $report->path) { + $target = $report->baseUrl.$report->path; + + if (ErrorPageSettings::url($target) !== $target) { return ''; } - return $report->query === '' ? $report->path : $report->path.'?'.$report->query; + return $report->query === '' ? $target : $target.'?'.$report->query; } /** diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 76da6756..feeb68a8 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -65,7 +65,8 @@ * WITH ONE EXCEPTION, AND IT IS NOT AN AUTHORED SENTENCE. A 405 the router raised keeps its verb sentence * — "That address does not accept a GET request. It accepts POST." — whichever way this key is set, because * nothing in it came from the application: ProblemMapper reads the verbs off the `Allow` header the ROUTER - * put on its own exception, and ErrorPage writes the words. This key governs the DISCLOSURE of what an + * put on its own exception, and ProblemMapper::methodSentence() writes the words for the page and for the + * document alike. This key governs the DISCLOSURE of what an * application said, and there is none to govern there — the same list of verbs is the `allowed` member of * the problem document published for the same failure. An operator who turns the key off for the * status-and-code page gets it everywhere else and gets this sentence still. diff --git a/packages/web/src/Error/ErrorReport.php b/packages/web/src/Error/ErrorReport.php index 7c38054d..5fe5d6d2 100644 --- a/packages/web/src/Error/ErrorReport.php +++ b/packages/web/src/Error/ErrorReport.php @@ -56,6 +56,20 @@ * fact grid still shows `path` alone: the grid is a statement about the request, and a query string is * where a session token or a search a person would rather not screenshot tends to live. * + * `baseUrl` IS THE THIRD PIECE OF THE SAME ADDRESS, and it exists because `path` is base-URL-STRIPPED. + * Laravel's `path()` is Symfony's `getPathInfo()`, which answers `orders/42` for a request to + * /app/index.php/orders/42 — the front controller's own prefix is deliberately not in it, because a + * route is matched on the path info and nothing else. The "Try again" link is the one place that + * absence is not cosmetic: on a deployment served under a base path, a href built from `path` alone + * names a URL the deployment never serves, so the primary action on every 5xx page points off the + * application. It is Symfony's `getBaseUrl()` — the same value `LoginPageAction` and the OAuth2 link + * builders prepend for exactly this reason — and it is '' for the ordinary rewrite-to-the-root + * deployment, where the concatenation is `path` unchanged. The grid is untouched by it for the same + * reason it omits the query: it states which resource was asked for, and the front controller is no + * more part of that than a search term is. ErrorPage::retry() puts the CONCATENATION through + * ErrorPageSettings::url(), never the halves, because a guard that checked the tail and trusted the + * head would have been asking about a string the page does not print. + * * @param list $frames * @param list $previous * @param list $allowed @@ -71,6 +85,10 @@ private function __construct( public string $query, public string $timestamp, public bool $detailed, + // The front controller's own prefix, '' when there is none — see the `path`/`query` note above. + // It carries a default so that a report assembled by hand (a renderer test, an Octane-shaped + // fixture) is not obliged to know about a deployment shape it is not exercising. + public string $baseUrl = '', public string $exceptionClass = '', public string $message = '', public string $location = '', @@ -119,6 +137,7 @@ public static function of(Throwable $e, Request $request, ErrorPageSettings $set query: $request->getQueryString() ?? '', timestamp: $timestamp, detailed: false, + baseUrl: $request->getBaseUrl(), reference: $reference, correlationId: $correlationId, allowed: $allowed, @@ -153,6 +172,7 @@ public static function of(Throwable $e, Request $request, ErrorPageSettings $set query: $public->query, timestamp: $public->timestamp, detailed: true, + baseUrl: $public->baseUrl, exceptionClass: $e::class, message: $e->getMessage(), location: SourcePaths::shorten($e->getFile(), $roots).':'.$e->getLine(), diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php index 37464a8a..6e50d4b4 100644 --- a/packages/web/src/Error/ProblemMapper.php +++ b/packages/web/src/Error/ProblemMapper.php @@ -193,9 +193,57 @@ private static function httpMessage(HttpExceptionInterface $e): string } /** - * The verbs a 405 permits live in the exception's `Allow` header, which the router always sets. HEAD is - * dropped from the sentence and the extension because Symfony adds it beside every GET and no person - * chooses it; it stays in the header the renderer copies through, where the standard wants it. + * What a 405 says, for WHICHEVER surface is asking — the problem document's `detail` and the HTML + * page's lede are both built here. + * + * ONE BUILDER BECAUSE THE VERBS ARE ONE FACT. The list is parsed off the router's `Allow` header once, + * HEAD-filtered once (Symfony adds it beside every GET and no person chooses it; it stays in the header + * the renderer copies through, where the standard wants it) and joined into prose once. Two copies of + * that joining is how a page and a document come to disagree about a failure whose whole content is a + * list of three words, and this wave exists because they had already come to disagree about others. + * + * THE TWO SURFACES STILL SAY DIFFERENT SENTENCES, AND THAT IS WHAT `$refused` IS FOR. A problem + * document is built from the throwable alone — toFireflyException() takes a throwable and nothing + * else, and is called both from a renderer that has a request and from places that have none — so it + * cannot name the verb the caller actually used, and says only what the address accepts. The HTML page + * has the request in hand and names both, because a person who has just been refused is owed the + * refusal and not only the menu. So the difference between the two sentences is exactly the clause the + * document has no way to write; it is one ternary here rather than two wordings in two files, and + * ErrorPageTest pins the pair against each other so neither can be reworded alone. + * + * WITH NO VERBS THERE IS NO MENU, and both surfaces say so the same way: a router that set an empty + * `Allow` header has told the caller nothing to act on, and naming the verb that failed on its own + * would be a sentence with no next step in it. The page never reaches this arm — lede() takes the 405 + * branch only when the list is non-empty, and falls through to its own reassurance otherwise — so the + * arm exists for the document, which must answer something for every 405 it is handed. + * + * @param list $allowed the verbs the address accepts, HEAD already dropped + * @param string $refused the verb the caller used, when the caller is known; '' when it is not + */ + public static function methodSentence(array $allowed, string $refused = ''): string + { + if ($allowed === []) { + return 'This address does not accept that method.'; + } + + // Written with "does not" rather than a contraction because that is the page's voice ("That page + // does not exist.", "You do not have access to that.") and because an apostrophe here would reach + // the markup as `'`. + $verbs = count($allowed) === 1 + ? $allowed[0] + : implode(', ', array_slice($allowed, 0, -1)).' or '.$allowed[count($allowed) - 1]; + + return $refused === '' + ? "This address only accepts {$verbs}." + : "That address does not accept a {$refused} request. It accepts {$verbs}."; + } + + /** + * The verbs a 405 permits live in the exception's `Allow` header, which the router always sets. They + * are published as an `allowed` extension member as well as spelled into the sentence, so a client + * never has to parse prose to learn them — and so the HTML page beside this document can print the + * same list without re-reading the header. The sentence itself comes from methodSentence(), which the + * page calls too; see there for why the document's wording is the one that names no refused verb. */ private static function methodNotAllowed(MethodNotAllowedHttpException $e): FireflyException { @@ -205,14 +253,8 @@ private static function methodNotAllowed(MethodNotAllowedHttpException $e): Fire explode(',', is_string($header) ? $header : ''), ), static fn (string $method): bool => $method !== '' && $method !== 'HEAD')); - $sentence = match (count($allowed)) { - 0 => 'This address does not accept that method.', - 1 => "This address only accepts {$allowed[0]}.", - default => 'This address only accepts '.implode(', ', array_slice($allowed, 0, -1)).' or '.$allowed[count($allowed) - 1].'.', - }; - return new FireflyException( - $sentence, + self::methodSentence($allowed), 'METHOD_NOT_ALLOWED', 405, ErrorCategory::Framework, diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index cf5390cf..ae946c19 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -1167,6 +1167,102 @@ ->toContain('Try again'); }); +it('keeps the base path a front controller is served under, so the retry is the request that failed', function () { + // `path()` IS `getPathInfo()`, AND IT IS BASE-URL-STRIPPED BY DESIGN: the router matches on the path + // info, so the front controller's own prefix is deliberately not in it. Built into an href on its own + // it names an address the deployment never serves — which makes the PRIMARY action on every 5xx page a + // 404, or a step off the application, on every box served under a base path. The family settled this + // spelling before this row existed (`$request->getBaseUrl().$path`, in LoginPageAction and the two + // OAuth2 link builders, pinned there by a case with this same name); retry() reuses it rather than + // inventing a second one. + // + // BUILT FROM A RAW $_SERVER ARRAY, like the guard case above, because a base URL exists only when + // SCRIPT_NAME is a prefix of REQUEST_URI — which is what a front-controller deployment has and what + // `Request::create()` does not produce. + $settings = new ErrorPageSettings(trace: false, hints: false, home: '/'); + + $render = static function (array $server) use ($settings): string { + $request = new Request([], [], [], [], [], $server + [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'app.example.com', + 'HTTP_HOST' => 'app.example.com', + 'SERVER_PORT' => '80', + ]); + + return ErrorPage::render( + ErrorReport::of(new RuntimeException('boom'), $request, $settings, dirname(__DIR__, 4), 500, 'Internal Server Error', '2026-01-01T00:00:00+00:00'), + $settings, + ); + }; + + $served = [ + 'SCRIPT_NAME' => '/app/index.php', + 'SCRIPT_FILENAME' => '/var/www/app/index.php', + 'PHP_SELF' => '/app/index.php', + ]; + + expect($render(['REQUEST_URI' => '/app/index.php/orders/42'] + $served)) + ->toContain('Try again') + // The FACT GRID is unchanged and still states the path alone: it says which resource was asked + // for, and the front controller is not part of that. Only the href needs the prefix. + ->toContain('
    GET /orders/42
    ') + // The query string rides on the end of the whole address, not between its halves. + ->and($render(['REQUEST_URI' => '/app/index.php/search?q=foo', 'QUERY_STRING' => 'q=foo'] + $served)) + ->toContain('Try again') + // AND THE GUARD IS ASKED ABOUT THE WHOLE HREF, NOT ITS TAIL. `getBaseUrl()` is raw — a prefix of + // REQUEST_URI matched against SCRIPT_NAME, not a value this package composed — so a check on the + // path alone would have been a question about a string the page does not print. Here the PATH is + // innocent and the base is the authority wearing a path's clothes; the offer is withheld whole. + ->and($render([ + 'REQUEST_URI' => '/\evil.example/index.php/orders/42', + 'SCRIPT_NAME' => '/\evil.example/index.php', + 'SCRIPT_FILENAME' => '/var/www/index.php', + 'PHP_SELF' => '/\evil.example/index.php', + ])) + ->not->toContain('Try again') + ->not->toContain('href="/\\') + ->toContain('Go home'); +}); + +it('builds the page\'s 405 sentence and the document\'s from one place, and pins where they differ', function () use ($page) { + // THE DIVERGENCE IS DECLARED, NOT DISCOVERED. lede() opens by saying the page and the problem document + // agree, and for the 405 they do not say the same words: the page names the verb that was REFUSED and + // the document cannot, because toFireflyException() is handed a throwable and no request. That is a + // real constraint rather than an oversight, so it is pinned here the way the 5xx lede is pinned + // against ProblemMapper::OPAQUE_WITH_REFERENCE above — both sentences read off the same exception, so + // the day either is reworded this case names the other. + // + // WHAT THEY DO SHARE IS THE BUILDER. The verb list, the HEAD filtering and the prose that joins three + // verbs into "PUT, PATCH or DELETE" live in ProblemMapper::methodSentence() and nowhere else; the two + // call sites differ by one argument. Two copies of that joining is exactly how a page and a document + // come to disagree about a failure whose whole content is a list of words. + $settings = new ErrorPageSettings(trace: false, hints: false); + $e = new MethodNotAllowedHttpException(['POST', 'HEAD'], 'The GET method is not supported for route orders.'); + + $html = $page($e, $settings, 405, 'Method Not Allowed', 'GET', '/orders'); + $document = ProblemMapper::authoredDetail($e); + + if (preg_match('#

    ([^<]+)

    #', $html, $matched) !== 1) { + throw new RuntimeException('The production 405 page no longer carries a lede for this test to read.'); + } + $lede = $matched[1]; + + expect($lede)->toBe(ProblemMapper::methodSentence(['POST'], 'GET')) + ->and($document)->toBe(ProblemMapper::methodSentence(['POST'])) + // The verbs agree — which is the only thing the docblocks and the config reference claim. + ->and($lede)->toContain('POST') + ->and($document)->toContain('POST') + // The sentences do not, and neither surface carries the other's wording. + ->and($lede)->not->toBe($document) + ->and($html)->not->toContain($document) + // The clause the document has no request to write is the whole of the difference. + ->and($lede)->toContain('GET') + ->and($document)->not->toContain('GET') + // HEAD is dropped once, for both, because Symfony adds it beside every GET and no person picks it. + ->and($lede)->not->toContain('HEAD') + ->and($document)->not->toContain('HEAD'); +}); + it('says something a reader does not already know, or falls back to the sentence that does', function () use ($page) { // THE MOST ORDINARY FAILURE A LARAVEL APPLICATION PRODUCES is `abort(403)` with no message, and the // problem document needs SOME `detail` for it — ProblemMapper::httpMessage() substitutes statusText(), diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 7cb8bfb5..680a2be5 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -969,9 +969,9 @@ // | ONE SENTENCE IS NOT GOVERNED BY THIS KEY: a 405 still names the verbs it accepts ("That // | address does not accept a GET request. It accepts POST.") with this off. That sentence is // | the FRAMEWORK's — the verbs come off the `Allow` header the router put on its own - // | exception, and the page writes the words — so there is nothing of YOURS in it for this key - // | to withhold, and the same list is already the `allowed` member of the problem document - // | published for the same failure. + // | exception, and the framework writes the words — so there is nothing of YOURS in it for + // | this key to withhold, and the same list is already the `allowed` member of the problem + // | document published for the same failure. // | // | Only sentences that were WRITTEN for a caller are ever carried: below 500, from a // | FireflyException or from an `abort(404, '…')`. At 500 and above nothing is authored — a From bbea8515a3dc482db30e97ebecd8cbb960b39ae8 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:10:07 -0700 Subject: [PATCH 42/78] docs(error-handling): describe the production page this wave really renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § The HTML error page still described the page as it was before Task 6: "Production shows the status, the reason, the code and the request's reference", closing with "nothing that names a class, a file or a row". The production page now also ledes with the application's own sentence — the browser suite asserts "That order does not exist." — which names a row, and renders an action row the section never mentioned; "the lede points at the cell" became true only of a 5xx. The commented excerpt elided `authored-detail`, `home`, `sign-in`, `support` and `actions` entirely, so an operator reading the module could not find the keys that govern any of it. The disclosure boundary is restated accurately — no class, no file, no trace and no framework hint, but the sentence the application wrote for the caller, which problem+json publishes as `detail` — the five keys are named in the excerpt, and two paragraphs state the rules this wave introduced: "Try again" is 5xx-and-GET/HEAD only and carries the base path, and a 405 names the verbs whatever `authored-detail` says. --- docs/modules/error-handling.md | 76 +++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/docs/modules/error-handling.md b/docs/modules/error-handling.md index 2789a368..b3e8e3e2 100644 --- a/docs/modules/error-handling.md +++ b/docs/modules/error-handling.md @@ -277,6 +277,13 @@ deployment wants to change: // // Default: 7. // 'excerpt-lines' => 7, // … +// 'authored-detail' => env('FIREFLY_WEB_ERROR_PAGE_AUTHORED_DETAIL', true), +// … +// 'home' => env('FIREFLY_WEB_ERROR_PAGE_HOME', '/'), +// 'sign-in' => env('FIREFLY_WEB_ERROR_PAGE_SIGN_IN', '/login'), +// 'support' => env('FIREFLY_WEB_ERROR_PAGE_SUPPORT', 'https://support.example.test'), +// 'actions' => env('FIREFLY_WEB_ERROR_PAGE_ACTIONS', true), +// … // 'copy-button' => env('FIREFLY_WEB_ERROR_PAGE_COPY_BUTTON', true), // … // 'json-paths' => 'api/*,webhooks/*', @@ -297,26 +304,55 @@ The reference both surfaces publish has two keys of its own: **`trace` is enforced where the data is gathered, not where it is printed.** With it off the framework never walks the stack, never opens a source file and never copies the exception message — so there is nothing -assembled for a template mistake to leak. Production shows the status, the reason, the code and the request's -**reference** — the same id the problem document publishes as `traceId`, which is the W3C trace id when the -request had a valid span and the correlation id when it did not, and the same value the response echoes on -`X-Trace-Id` — so the 500 page reads "quote the reference below if you report it" and the id a person -screenshots is the one a trace search resolves. The page prints that id **once**, in a **Reference** cell -that is `user-select:all`: one click takes the whole of it, with no JavaScript and no dragging a selection -across a wrapped uuid. The lede points at the cell rather than spelling the id into prose a second time, -which is why the page's sentence no longer matches the problem document's word for word — a payload has no -cell to point at, so it keeps the id inline. Both surfaces still carry the same id, and that is the part a -ticket and a trace search need. A **Copy** button sits beside the cell wherever the browser can honour one, -behind `firefly.web.error-page.copy-button`: it ships `hidden` and is revealed by the page's only script, so -scripts off, a Content-Security-Policy that refuses inline scripts, or a plain-http origin -(`navigator.clipboard` is a secure-context API) leave no control rather than a dead one, and a copy the -browser refuses at click time says `Copy failed` instead of failing silently. When the two ids differ the -page carries a **Correlation** cell beside the Reference one, holding the `X-Correlation-Id` value; when -they are the same string the cell is omitted, because two cells repeating one value teach a reader the ids -are interchangeable. That is enough to quote into a ticket and grep in a log, and nothing that names a -class, a file or a row. The page's own advice about *how* to turn traces on is suppressed outside -non-production environments too, because naming the framework and a config key to an anonymous visitor is a -free hint about your stack. +assembled for a template mistake to leak. Production shows the status, the reason, the code, one **lede** +sentence, a row of **actions**, and the request's **reference** — the same id the problem document publishes +as `traceId`, which is the W3C trace id when the request had a valid span and the correlation id when it did +not, and the same value the response echoes on `X-Trace-Id` — so the 500 page reads +"quote the reference below if you report it" and the id a person screenshots is the one a trace search +resolves. The page prints that id **once**, in a **Reference** cell that is `user-select:all`: one click +takes the whole of it, with no JavaScript and no dragging a selection across a wrapped uuid. On a 5xx the +lede points at that cell rather than spelling the id into prose a second time, which is why the page's +sentence no longer matches the problem document's word for word — a payload has no cell to point at, so it +keeps the id inline. Both surfaces still carry the same id, +and that is the part a ticket and a trace search need. A **Copy** button sits beside the cell wherever the +browser can honour one, behind `firefly.web.error-page.copy-button`: it ships `hidden` and is revealed by the +page's only script, so scripts off, a Content-Security-Policy that refuses inline scripts, or a plain-http +origin (`navigator.clipboard` is a secure-context API) leave no control rather than a dead one, and a copy the +browser refuses at click time says `Copy failed` instead of failing silently. When the two ids differ the page +carries a **Correlation** cell beside the Reference one, holding the `X-Correlation-Id` value; when they are +the same string the cell is omitted, because two cells repeating one value teach a reader the ids are +interchangeable. That is enough to quote into a ticket and grep in a log, and **no class, no file, no trace +and no framework hint** — the boundary is the raw exception and everything downstream of it, not every word +about the failure: the lede below is the sentence the application itself wrote for the caller, which +problem+json publishes as `detail` for the same failure. The page's own advice about *how* to turn traces on +is suppressed outside non-production environments too, because naming the framework and a config key to an +anonymous visitor is a free hint about your stack. + +**The lede is the problem document's own sentence** (`firefly.web.error-page.authored-detail`, default +`true`). An `abort(404, 'No such tenant.')`, or a `ResourceNotFoundException` carrying `Order 42 does not +exist.`, says that to the person and to the client alike — one failure, one wording, whichever surface +answered. What counts as authored is `ProblemMapper`'s decision and not this key's: below 500 only, and with +every sentence the *framework* generated already replaced, so a `QueryException`'s SQL and the +route-model-binding 404s that name a model class and a primary key are never ledes. Set the key `false` and +the lede falls back to the generic sentence for the status — the status-and-code page — with two exceptions +that do not move. A **405 always names the verbs**: "That address does not accept a GET request. It accepts +POST.", built from the `Allow` header the *router* set, so there is nothing of yours in it for the key to +withhold, and the same list is the document's `allowed` member. And a bare `abort(403)` authored nothing: the +document publishes the reason phrase because it needs some `detail`, and the page declines to lede with a word +already printed beside the status code. + +**The action row offers what fits the status, and nothing it was not given**: `home` (default `/`), `sign-in` +and `support` (both **empty** by default — the values in the reference above are examples, and an empty one +offers no link rather than a guessed route name), and `actions` (default `true`) to switch the row off +entirely. A 401 gets **Sign in**; every page gets **Go home** and **Contact support** wherever those are set. +**Try again** is offered on a **5xx and only for a GET or a HEAD**, because it is a plain link and a link is a +GET: it carries the address and the query string of the request that failed and it cannot carry the verb or +the body, so on a failed POST it would either land the reader on the 405 page or send a different request +under a label that says "again". The address it names is the request's own — base path and all, so a +deployment served under a front controller gets a link back to the URL it really serves — and it goes through +the same scheme guard as every configured href on the page, which drops `javascript:`, `data:`, a +protocol-relative `//host` and its backslash spelling `/\host`. When the guard refuses, the page makes no +retry offer at all rather than one it cannot spell truthfully. **Overriding it.** `views` hands a status — or `default` — to your own Blade view. The view receives the same `$error` report the built-in page gets, so it is bound by the same `trace` gate and cannot print a stack From a6362cfbb06d04368aa2101edaee2c996cba90bd Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:19:30 -0700 Subject: [PATCH 43/78] fix(web): the problem renderer answers with a document on any payload instead of throwing out of the error handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) was a live failure mode on the one code path that must not have one. A single byte that is not valid UTF-8 anywhere in the document raised a JsonException straight OUT of render(), so the handler failed while handling the error and the caller received no document at all — a blank 500 from the web server with the real failure buried underneath. Those bytes arrive as a matter of routine: a driver message quoting a latin-1 column value, a request header echoed into an extension member at the throw site, a file name off a filesystem that is not UTF-8. encode() answers that case with JSON_INVALID_UTF8_SUBSTITUTE — the offending bytes become U+FFFD and the document is still the document it was built to be — and catches what json_encode still refuses: recursion, a resource, an INF or NAN an application put in an extension member. minimal() then rebuilds six members whose types are checked rather than trusted, keeping the status and the code a client branches on and dropping everything an application contributed, since one of those values is why we are here. Both are total: neither throws and neither returns false. The book's verbatim excerpt of render() is re-quoted from the file it names, in both languages, and each gains a sentence saying what self::encode() is and why it may not throw. --- book/src-es/04-first-http-api.md | 6 +- book/src/04-first-http-api.md | 6 +- .../src/Exception/ProblemDetailsRenderer.php | 67 ++++++++++++++++++- .../Exception/ProblemDetailsRendererTest.php | 55 +++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index 82213142..a4d0aa8c 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -448,11 +448,13 @@ final class ProblemDetailsRenderer // … return new Response( - json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + self::encode($payload), $exception->httpStatus(), $headers, ); } + + // … } ``` @@ -460,6 +462,8 @@ final class ProblemDetailsRenderer El corte que hay encima del `return` oculta el resto del trabajo con cabeceras: las cabeceras que una `HttpExceptionInterface` ya trae se copian sobre la respuesta — el `Allow` de un `405`, entre ellas — y un `503` gana además `Retry-After: 5`. +`self::encode()` es el cuerpo, y es total a propósito: un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse, y cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo que lleva el estado, el código y una frase opaca. Un renderizador que lanzara aquí fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. + Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se convierte un throwable cualquiera. Esa regla vive una clase más allá, en `Firefly\Web\Error\ProblemMapper`, porque la página de error HTML del Capítulo 10 necesita la respuesta idéntica y dos copias de ella acabarían dándole a un navegador y a un cliente de API códigos distintos para el mismo fallo: diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md index bcf2c5ef..654356a4 100644 --- a/book/src/04-first-http-api.md +++ b/book/src/04-first-http-api.md @@ -448,11 +448,13 @@ final class ProblemDetailsRenderer // … return new Response( - json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + self::encode($payload), $exception->httpStatus(), $headers, ); } + + // … } ``` @@ -460,6 +462,8 @@ final class ProblemDetailsRenderer The cut above the `return` hides the rest of the header work: the headers an `HttpExceptionInterface` already carries are copied onto the response — a `405`'s `Allow` among them — and a `503` additionally gains `Retry-After: 5`. +`self::encode()` is the body, and it is total on purpose: a byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised, and anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document carrying the status, the code and an opaque sentence. A renderer that threw here would fail while handling the failure, and the caller would receive no document at all. + What `render()` deliberately does **not** decide is which `FireflyException` an arbitrary throwable becomes. That rule lives one class along, in `Firefly\Web\Error\ProblemMapper`, because the HTML error page of Chapter 10 needs the identical answer and two copies of it would eventually hand a browser and an API client different codes for the same failure: diff --git a/packages/web/src/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index bc5007f7..9256ed20 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -6,13 +6,16 @@ use DateTimeImmutable; use DateTimeInterface; +use Firefly\Kernel\Error\ErrorCategory; use Firefly\Kernel\Error\ErrorResponse; +use Firefly\Kernel\Error\ErrorSeverity; use Firefly\Web\Error\ErrorPageSettings; use Firefly\Web\Error\ProblemMapper; use Firefly\Web\Filter\CorrelationIdFilter; use Firefly\Web\Trace\TraceContext; use Illuminate\Http\Request; use Illuminate\Http\Response; +use JsonException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; @@ -43,6 +46,10 @@ * thing that changes the value is switching tracing on. The correlation id is not absorbed: it keeps * `X-Correlation-Id` untouched and gains `correlationId`, and the trace id is echoed on its own header * (`firefly.web.trace-id.header`, `X-Trace-Id` by default, '' to disable) only when there is one. + * - A BODY, unconditionally. The encoder is total: an invalid UTF-8 byte is substituted rather than + * raised, and anything json_encode still refuses falls back to a minimal document. This method used to + * throw JsonException out of the error handler on a latin-1 byte in a driver message, which turned a + * described failure into a blank 500 with no document at all. */ final class ProblemDetailsRenderer { @@ -105,9 +112,67 @@ public function render(Throwable $e, Request $request): Response } return new Response( - json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + self::encode($payload), $exception->httpStatus(), $headers, ); } + + /** + * The payload as JSON, whatever the payload turns out to contain. + * + * THE RENDERER RUNS WHILE THE APPLICATION IS ALREADY FAILING, and json_encode had a live failure mode on + * exactly that path: JSON_THROW_ON_ERROR turns a single byte that is not valid UTF-8 — anywhere in the + * document — into a JsonException thrown OUT of this method, so the error handler fails while handling + * the error and the caller receives no document at all. Those bytes are not exotic: a driver message + * quoting a latin-1 column value, a request header echoed into an extension member at the throw site, a + * file name off a filesystem that is not UTF-8. + * + * JSON_INVALID_UTF8_SUBSTITUTE answers that case properly — the offending bytes become U+FFFD and the + * document is still the document. The try/catch is the belt to that pair of braces: recursion, a + * resource, an INF or NAN an application put in an extension member are all things json_encode still + * refuses, and none of them is a reason to answer a caller with nothing. + * + * @param array $payload + */ + private static function encode(array $payload): string + { + try { + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + } catch (JsonException) { + $json = false; + } + + return is_string($json) ? $json : self::minimal($payload); + } + + /** + * A document that CANNOT fail to encode: six members, each rebuilt from a value whose type is checked + * here rather than trusted, with every string passing through the same substitution. + * + * The status and the code are kept when they are what they claim to be, because they are the two members + * a client branches on; everything an application contributed — every extension member, the detail, the + * validation errors — is dropped, since one of them is why we are here. The literal at the bottom is + * unreachable and is written anyway: a renderer on the error path does not get to assume. + * + * @param array $payload + */ + private static function minimal(array $payload): string + { + $status = is_int($payload['status'] ?? null) ? $payload['status'] : 500; + $code = is_string($payload['code'] ?? null) ? $payload['code'] : 'INTERNAL_ERROR'; + + $json = json_encode([ + 'status' => $status, + 'title' => ErrorResponse::titleFor($status), + 'code' => $code, + 'category' => ErrorCategory::Internal->value, + 'severity' => ErrorSeverity::Error->value, + 'detail' => ProblemMapper::OPAQUE, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + + return is_string($json) + ? $json + : '{"status":500,"title":"Internal Server Error","code":"INTERNAL_ERROR","category":"internal","severity":"error"}'; + } } diff --git a/packages/web/tests/Exception/ProblemDetailsRendererTest.php b/packages/web/tests/Exception/ProblemDetailsRendererTest.php index 720716fe..6df3c0d0 100644 --- a/packages/web/tests/Exception/ProblemDetailsRendererTest.php +++ b/packages/web/tests/Exception/ProblemDetailsRendererTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Config\Config; +use Firefly\Kernel\Exception\Business\ConflictException; use Firefly\Kernel\Exception\Business\PaymentRequiredException; use Firefly\Kernel\Exception\Business\ResourceNotFoundException; use Firefly\Kernel\Exception\Infrastructure\ServiceUnavailableException; @@ -274,3 +275,57 @@ ->and($body['correlationId'])->toBe('corr-42') ->and($response->headers->has('X-Trace-Id'))->toBeFalse(); }); + +/* + * THE ERROR HANDLER MUST NOT FAIL WHILE HANDLING THE ERROR. json_encode with JSON_THROW_ON_ERROR raises out + * of render() on any byte that is not valid UTF-8, and those bytes arrive on this path as a matter of + * routine: a driver message quoting a latin-1 column, a request header echoed into an extension member, a + * file name off a non-UTF-8 filesystem. What the caller got was not a worse document — it was NO document, + * a blank 500 from the web server, with the real failure buried under a JsonException. + */ +it('substitutes an invalid byte rather than throwing out of the renderer', function () { + $broken = (new ConflictException("caf\xE9 is closed", 'CAFE_CLOSED'))->withExtensions(['who' => "ada\xB1\x31"]); + + $response = (new ProblemDetailsRenderer(new ErrorPageSettings(disclose: true)))->render($broken, Request::create('/api/x')); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + expect($response->getStatusCode())->toBe(409) + ->and($response->headers->get('Content-Type'))->toBe('application/problem+json') + ->and($payload['code'])->toBe('CAFE_CLOSED') + // Substituted, not dropped: the document still describes the failure it was built for. + ->and($payload['detail'])->toContain('is closed') + ->and($payload['who'])->toContain('ada'); +}); + +it('falls back to a minimal document rather than raising when a member cannot be encoded at all', function () { + // INF is the case JSON_INVALID_UTF8_SUBSTITUTE does not cover: a float an application put in an + // extension member at the throw site. The document that comes back is smaller and still true. + $impossible = (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['ratio' => INF]); + + $response = (new ProblemDetailsRenderer)->render($impossible, Request::create('/api/x')); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + expect($response->getStatusCode())->toBe(409) + ->and($payload['status'])->toBe(409) + ->and($payload['title'])->toBe('Conflict') + ->and($payload['code'])->toBe('LEDGER_CONFLICT') + ->and($payload['category'])->toBe('internal') + ->and($payload['detail'])->toBe(ProblemMapper::OPAQUE) + // The member that could not be encoded is simply not there; it is not a reason to answer nothing. + ->and($payload)->not->toHaveKey('ratio'); +}); + +it('answers with a document even when the status itself is the only thing left', function () { + // The last branch, exercised directly: whatever the payload holds, the renderer returns valid JSON. + $response = (new ProblemDetailsRenderer)->render( + (new ConflictException('x', 'X'))->withExtensions(['a' => NAN, 'b' => "\xC3\x28"]), + Request::create('/api/x'), + ); + + expect(json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR))->toBeArray() + ->and($response->getStatusCode())->toBe(409); +}); From 64f826e4650dff123bc5128ffd6fcdc0d6748afb Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:30:25 -0700 Subject: [PATCH 44/78] fix(admin): size the HTTP traffic When column for the dated stamp its formatter falls back to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column was declared `TableColumn::stamp('timestamp', 'When', ch: 12)`, sized for `2h ago`, while Format::since stops giving an age past its 86400-second arm and gives `2026-09-22 20:49` — sixteen characters. Measured against the sheet, twelve characters is a 118px box whose text starts after the 14px left padding and runs 115px, so `table.ftable td{overflow:hidden}` took the tail off; Chromium reports the overflow as 26px. `td.t-stamp` carried neither `text-overflow:ellipsis` nor a `title`, so the cut was a hard mid-glyph clip and the operator read `2026-09-22 20:` as the whole value. It is a regression: before the listing wave the cell was `td.mono.dim.tight` on a `table-layout:auto` table, where `width:1%` shrank the column to its content. It is also reachable in ordinary operation, since the exchange ring is cache-backed precisely so it outlives a request. The column is now `ch: 16` — 14 is the first width that stops clipping and 16 the first that fits without spilling into the right padding, at both densities. The cell carries the full instant on its title through a new Format::instant(), and `td.t-stamp` gains the ellipsis the other clipped kinds in the vocabulary already have, so a stamp that ever outgrows its column again says so. The fixture could not see any of this: its oldest exchange was 7200 seconds old, one arm short of the branch that sizes the column. AdminTableCapstoneTestCase gains an archived row at a fixed past instant, FormatStampTest pins the alphabets both widths are derived from, and the browser suite measures the widest stamp in the column as the page really lays it out — that assertion reports a 26px clip at the old width. --- packages/admin/resources/views/http.blade.php | 8 +- .../admin/resources/views/layout.blade.php | 6 +- packages/admin/src/Format.php | 26 ++++++- packages/admin/src/Web/AdminAction.php | 11 ++- .../admin/tests/AdminTableListingTest.php | 73 ++++++++++++++++++- packages/admin/tests/FormatStampTest.php | 64 ++++++++++++++++ .../Support/AdminTableCapstoneTestCase.php | 43 +++++++++-- tests/Browser/ObservabilityTest.php | 53 ++++++++++++++ 8 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 packages/admin/tests/FormatStampTest.php diff --git a/packages/admin/resources/views/http.blade.php b/packages/admin/resources/views/http.blade.php index 3839640e..3e7e2589 100644 --- a/packages/admin/resources/views/http.blade.php +++ b/packages/admin/resources/views/http.blade.php @@ -30,7 +30,13 @@
    @foreach ($slice->rows as $exchange) - + {{-- The age is lossy both ways — `2h ago` does not say which two hours, and + the dated arm past 24h rounds the seconds off — so the full instant rides + on the title, the way the path and the two ids on this row already do. + Conditional rather than an empty string, because a row with no timestamp + draws an em-dash and has no instant to offer: `title=""` on it would be a + tooltip promising a value it does not have. --}} + diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 72a19015..59a12b73 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -367,7 +367,11 @@ table.ftable td.t-pill,table.ftable th.t-pill{white-space:nowrap} table.ftable td.t-num,table.ftable th.t-num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap} table.ftable th.t-num a{justify-content:flex-end} - table.ftable td.t-stamp{font-family:var(--mono);font-size:12px;color:var(--ink-3);white-space:nowrap} + /* `text-overflow` for the same reason the three kinds below carry it. A rigid column is sized from + the alphabet its formatter can emit, and the day a formatter grows an arm wider than that, the + cell has to SAY it was cut: without this, `overflow:hidden` takes the tail off mid-glyph and a + truncated timestamp reads as a complete, wrong one. The full instant is on the cell's title. */ + table.ftable td.t-stamp{font-family:var(--mono);font-size:12px;color:var(--ink-3);white-space:nowrap;text-overflow:ellipsis} table.ftable td.t-meter{vertical-align:middle} table.ftable td.t-actions{white-space:nowrap} /* Clipped to one line with the full value on the title: a table whose row height depends on its diff --git a/packages/admin/src/Format.php b/packages/admin/src/Format.php index 90ea575d..11d48653 100644 --- a/packages/admin/src/Format.php +++ b/packages/admin/src/Format.php @@ -85,7 +85,16 @@ public static function measurement(string $meterName, float $value): string return self::count($value); } - /** "3 minutes ago" for a unix timestamp, or an ISO instant when it is older than a day. */ + /** + * "3 minutes ago" for a unix timestamp, or a dated instant when it is older than a day. + * + * THE WIDEST THING THIS EMITS IS SIXTEEN CHARACTERS, not the six of `2h ago`, and a column sized for + * the age alphabet clips the date one. Past the 86400-second arm an age stops being informative — "37h + * ago" is a number the reader has to do arithmetic on — so the last arm gives the instant itself, and + * `2026-09-22 20:49` is what a `Stamp` column has to be wide enough to draw. See + * AdminAction::data()'s HTTP listing, whose When column is sized from this method's alphabet, and + * FormatStampTest, which pins both widths so a future arm cannot widen one without failing. + */ public static function since(float $timestamp, float $now): string { $delta = max(0.0, $now - $timestamp); @@ -99,6 +108,21 @@ public static function since(float $timestamp, float $now): string }; } + /** + * The full instant behind a `since()` age — `2026-09-22 20:49:26`, the nineteen characters + * `TableColumn::stamp()` takes as its default width. + * + * This is what a stamp cell carries on its `title`, and it exists because an age is LOSSY in both + * directions: `2h ago` does not say which two hours, and the dated arm rounds the seconds off. A reader + * correlating an exchange against a log line needs the instant, and a cell whose text is clipped by its + * column needs somewhere to recover the value from — the same contract `t-token`, `t-path` and `t-line` + * already keep with the full value on the title. + */ + public static function instant(float $timestamp): string + { + return date('Y-m-d H:i:s', (int) $timestamp); + } + /** The share one value takes of a maximum, clamped to 0..100 for a bar width. */ public static function percent(float $value, float $max): float { diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index f2b4c595..04df4df2 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -437,7 +437,16 @@ private function data(Request $request, string $slug): array 'http', $this->exchanges(), TableView::of( - TableColumn::stamp('timestamp', 'When', ch: 12), + // SIXTEEN, BECAUSE THAT IS THE ALPHABET Format::since CAN EMIT — not the six of + // `2h ago`. Past its 86400-second arm the formatter stops giving an age and gives + // `2026-09-22 20:49`, and this ring is cache-backed on purpose (the page's own empty + // state says so), so a low-traffic or freshly-idle application routinely lists + // requests older than a day. Measured against the sheet: at `ch: 12` the column is a + // 118px box whose text starts after the 14px left padding and runs 115px, so the last + // glyph and a half are cut off by `table.ftable td{overflow:hidden}`. 14 is the first + // width that stops clipping and 16 the first that fits without spilling into the right + // padding, at both densities. + TableColumn::stamp('timestamp', 'When', ch: 16), TableColumn::pill('method', 'Method'), TableColumn::path('path', 'Path', weight: 6), TableColumn::pill('status', 'Status', ch: 6), diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index 7ca52483..11b25fc7 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -496,11 +496,76 @@ expect($body)->toContain('↑'); - // Oldest first really is oldest first: the 500 the fixture recorded two hours ago leads, and the 200 it - // recorded two seconds ago is behind it. + // Oldest first really is oldest first: the archived 503 from a fixed instant days back leads, the 500 + // recorded two hours ago is behind it, and the 200 from two seconds ago is behind both. An ISO string + // and a relative one order against each other correctly because the row carries EPOCH SECONDS — the + // parse happens in AdminAction::epoch(), not in the comparator. preg_match('#]*>(.*?)#s', $body, $rows); - expect(strpos($rows[1] ?? '', 'code err'))->toBeInt() - ->toBeLessThan((int) strpos($rows[1] ?? '', 'code ok')); + $tbody = $rows[1] ?? ''; + preg_match('##', $tbody, $first); + + expect($first[1] ?? '')->toBe(date('Y-m-d H:i', $this->archivedExchangeEpoch())) + ->and(strpos($tbody, 'code err'))->toBeInt() + ->toBeLessThan((int) strpos($tbody, 'code ok')); +}); + +/** + * A RIGID COLUMN IS A PROMISE ABOUT ITS FORMATTER, AND THIS IS THE ONE THAT WAS BROKEN. + * + * The When column was declared `ch: 12` — sized for `2h ago` — while Format::since has a fifth arm that + * gives `2026-09-22 20:49`, sixteen characters, as soon as an exchange is older than a day. Measured + * against the sheet, twelve characters is a 118px box whose text starts after the 14px left padding and + * runs 115px, so `table.ftable td{overflow:hidden}` took the last glyph and a half off the end and + * `td.t-stamp` carried neither an ellipsis nor a title to recover the value from: the operator read + * `2026-09-22 20:` and had nothing to tell them it was cut. Reachable in ordinary operation, not in a + * contrived one — the ring is cache-backed precisely so it survives the process, which is what the page's + * own empty state tells the reader to configure. + * + * THE FIXTURE COULD NOT SEE IT. Its oldest exchange was 7200 seconds old, which renders `2h ago`: one arm + * short of the branch that sizes the column. ARCHIVED_EXCHANGE_AT is the row that reaches it. + * + * What a response-level test can assert is the pair that has to agree — the widest string the formatter + * emits, and the width the colgroup declares for it. The pixels are FormatStampTest's arithmetic and the + * browser suite's `scrollWidth <= clientWidth`; this is the invariant between them. + */ +it('sizes the When column for the dated stamp its formatter falls back to, not for an age', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/http')->assertStatus(200)->getContent(); + $epoch = $this->archivedExchangeEpoch(); + $stamp = date('Y-m-d H:i', $epoch); + + expect(strlen($stamp))->toBe(16) + // Sixteen characters of column for sixteen characters of stamp. `ch: 12` emitted + // `calc(12ch + 2 * var(--row-x))` here, and that is the regression. + ->and($body)->toContain('') + // The whole stamp, in the cell, not a prefix of it — and the instant it rounds the seconds off, + // on the title, so a reader correlating this exchange against a log line has the seconds back. + ->and($body)->toContain('') + // And the relative ages still render as ages: widening the column did not turn the page into a + // wall of timestamps. The two-hour row is the one asserted because it is the one whose rendering + // cannot drift while the suite runs — an age in seconds or minutes would. + ->and($body)->toContain('>2h ago'); +}); + +/** + * THE STAMP DEGRADES THE WAY EVERY OTHER CLIPPED KIND DOES, if it ever outgrows its column again. + * + * `t-token`, `t-path` and `t-line` all clip to one line and carry the full value on a title, so the reader + * sees an ellipsis and can recover what was elided. `t-stamp` had `overflow:hidden` from the shared rule + * and neither of those, which is why a column four characters short of its own formatter read as a + * complete — and wrong — value rather than as a truncated one. Widening the column fixes today's stamp; + * this is what keeps tomorrow's honest. + */ +it('gives the stamp cell the ellipsis and the title every other clipped kind has', function () { + /** @var AdminTableCapstoneTestCase $this */ + $body = (string) $this->get('/firefly/http')->assertStatus(200)->getContent(); + + expect($body)->toContain('table.ftable td.t-stamp{') + ->toMatch('/table\.ftable td\.t-stamp\{[^}]*text-overflow:ellipsis/') + // A row with no timestamp draws an em-dash and has no instant to offer, so it gets no title at + // all: `title=""` is a promise of a value that is not there, and tests/Browser/ObservabilityTest + // asserts the traffic page never renders one. + ->and($body)->not->toContain('declares it now, which is the same idea this block invented and the rest of the sheet + has finally caught up with. */ - /* THE CLASS IS `datatable`, NOT `grid`. It was `grid`, which is also this layout's own utility for - a CSS grid of panels — so the table became a grid CONTAINER, thead and tbody became independent - blocks, and the two rows laid out their columns separately: headers bunched into the left third - with the values spread across the full width beneath them. A one-word collision, invisible in the - markup, and only findable by asking the browser what `display` the table had ended up with. - - A CAP, NOT A COLLAPSE. This was `max-width:0`, which is the trick for clipping a cell in a - table that has explicit column widths — and this table has none, so auto-layout sized every - column from its HEADER while the values overflowed their boxes: headers bunched into the left - third and data spread across the full width, misaligned from the row above it. A real cap lets - auto-layout size a column from its content up to a limit, which is what keeps the two rows in - the same grid. */ - table.datatable{table-layout:auto} table.datatable td.cell{max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px;vertical-align:middle} table.datatable td.cell .v,table.datatable td.cell .idv{overflow:hidden;text-overflow:ellipsis;display:block} table.datatable thead th{vertical-align:middle} diff --git a/packages/admin/src/Data/DataFilter.php b/packages/admin/src/Data/DataFilter.php index ca68a90e..60fbe0ca 100644 --- a/packages/admin/src/Data/DataFilter.php +++ b/packages/admin/src/Data/DataFilter.php @@ -78,29 +78,46 @@ public function label(): string } /** - * The query-string form of a list of filters, so every link that must preserve them is built from one - * place. A single equality keeps the short `fk`/`fv` spelling that relation links use. + * This filter set as query PARAMETERS, which is the form every link builder wants. + * + * A LINK CARRIES A FILTER AS DATA, NOT AS A STRING IT WAS HANDED. `ListingQuery` is given this array as + * the parameters it must not lose, and it writes them out with `http_build_query` alongside its own — + * which is what lets a sort link, a page link and a search form each rebuild the whole URL from parsed + * values rather than concatenating someone else's fragment onto their own. * * @param list $filters + * @return array> */ - public static function toQuery(array $filters): string + public static function toParameters(array $filters): array { if ($filters === []) { - return ''; + return []; } + // TWO SPELLINGS, ONE MEANING. `fk`/`fv` is a single equality and is what every relation link + // produces — short enough to read in a status bar. Both are validated identically downstream. if (count($filters) === 1 && $filters[0]->operator === self::EQ) { - return 'fk='.urlencode($filters[0]->column).'&fv='.urlencode($filters[0]->value); + return ['fk' => $filters[0]->column, 'fv' => $filters[0]->value]; } - $parts = []; - foreach ($filters as $filter) { - $parts[] = 'fc[]='.urlencode($filter->column) - .'&fo[]='.urlencode($filter->operator) - .'&fv[]='.urlencode($filter->value); - } + return [ + 'fc' => array_map(static fn (self $filter): string => $filter->column, $filters), + 'fo' => array_map(static fn (self $filter): string => $filter->operator, $filters), + 'fv' => array_map(static fn (self $filter): string => $filter->value, $filters), + ]; + } - return implode('&', $parts); + /** + * The query-string form, for the one caller that still wants a string. + * + * Built by http_build_query rather than by concatenating urlencode() calls: the hand-built version was + * correct and was one edit away from not being, which is exactly the class of bug this wave is removing. + * + * @param list $filters + */ + public static function toQuery(array $filters): string + { + return http_build_query(self::toParameters($filters)); } /** A one-line description of what this filter narrows to, for the banner above a filtered listing. */ diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php index e59b1434..3cbbd039 100644 --- a/packages/admin/src/Data/DataQueryEngine.php +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -182,7 +182,7 @@ private function fetch( ?string $term, array $filters = [], ): array { - $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction)); + $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction, $schema)); if (($term !== null || $filters !== []) && $repository instanceof EloquentRepository) { $specifications = []; @@ -341,7 +341,14 @@ private function fetchInPhp( $matched, )); - usort($matched, static function (array $a, array $b) use ($sort, $direction, $compare): int { + // The same tiebreak the SQL paths get, for the same reason: `usort` is stable in PHP 8, but the + // ARRAY it is stabilising is rebuilt from the repository on every request, so stability of the + // sort is not stability of the page boundary. See sort() above. Like the empty rank above it, + // and for the same reason, it sits OUTSIDE the direction flip: an identity is not a second + // ordering. + $tiebreak = $schema->identifier; + + usort($matched, static function (array $a, array $b) use ($sort, $direction, $compare, $tiebreak): int { $left = $a['values'][$sort] ?? null; $right = $b['values'][$sort] ?? null; @@ -353,7 +360,13 @@ private function fetchInPhp( $comparison = $compare($left, $right); - return $direction === 'desc' ? -$comparison : $comparison; + if ($direction === 'desc') { + $comparison = -$comparison; + } + + return $comparison !== 0 || $tiebreak === null || $tiebreak === $sort + ? $comparison + : RowComparator::compare($a['values'][$tiebreak] ?? null, $b['values'][$tiebreak] ?? null); }); } @@ -455,15 +468,38 @@ private function sortColumn(DataSchema $schema, ?string $requested): ?string : null; } - private function sort(?string $column, string $direction): ?Sort + /** + * The ORDER BY a listing goes out with: what was asked for, then the identifier as a tiebreak. + * + * EVERY LISTING IS ORDERED, EVEN WHEN NOBODY ASKED — that much was already true, and it is why + * sortColumn() falls back to the identifier. What was missing is the second half. Ordering by a column + * with duplicate values leaves the tied rows in whatever order the engine finds convenient, and it is + * allowed to find a different one convenient for the query behind page 1 and the query behind page 2: + * a row is then returned on both and another on neither, and the operator reads a table that is missing + * records which are really there. `ORDER BY status DESC, id ASC` has no such freedom. + * + * THE TIEBREAK IS ALWAYS ASCENDING. It is an identity, not a second ordering — mirroring it with the + * primary direction would make the order WITHIN a tie depend on the direction it is breaking the tie + * inside, which is the same instability with extra steps. And it is appended only when the identifier + * is not already the sort column, because `ORDER BY id DESC, id ASC` is at best noise and at worst an + * index the planner declines to use. + */ + private function sort(?string $column, string $direction, DataSchema $schema): ?Sort { if ($column === null) { return null; } $sort = Sort::by($column); + if ($direction === 'desc') { + $sort = $sort->descending(); + } + + $identifier = $schema->identifier; - return $direction === 'desc' ? $sort->descending() : $sort; + return $identifier !== null && $identifier !== $column && in_array($identifier, $schema->sortable(), true) + ? $sort->and(Sort::by($identifier)) + : $sort; } private function term(?string $search): ?string diff --git a/packages/admin/src/Table/ListingQuery.php b/packages/admin/src/Table/ListingQuery.php index 29d9f31c..ca479d41 100644 --- a/packages/admin/src/Table/ListingQuery.php +++ b/packages/admin/src/Table/ListingQuery.php @@ -221,6 +221,38 @@ public function carrying(array $parameters): self ); } + /** + * The same listing at the size it was actually served. + * + * THIS EXISTS FOR A SECOND BOUND DOWNSTREAM. Every actuator listing is sliced by `InMemoryListing` at + * exactly `$size`, so for those this is never called. The data browser is not: `DataBrowser::list()` + * applies `firefly.admin.data.max-page-size` on top of the table's own offered set, because a page size + * that is merely large on an actuator payload can materialise a whole table into PHP memory on a + * repository that cannot page. When that cap is the tighter of the two, the rows come back at ITS size + * while every number a pager draws — the range readout, the last page, whether `Next` is live — would + * still be computed from the size that was asked for: on `table.page-size=100` over + * `data.max-page-size=50` the pager would claim half as many pages as there are and disable `Next` with + * the second half of the table still unreached. So the query is re-stated at the size that was served, + * and every link it then writes carries that size rather than the one the bound refused. + */ + public function sized(int $size): self + { + return $size === $this->size ? $this : new self( + settings: $this->settings, + path: $this->path, + page: $this->page, + size: $size, + sort: $this->sort, + direction: $this->direction, + search: $this->search, + sortable: $this->sortable, + carried: $this->carried, + qualifier: $this->qualifier, + defaultSort: $this->defaultSort, + defaultDirection: $this->defaultDirection, + ); + } + /** * The parameters worth writing down: everything that is set and is not already the default. * diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 04df4df2..dba017d1 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -339,26 +339,41 @@ private function dataPage(Request $request): SymfonyResponse ]), 200); } - $page = (int) ($request->query('page') ?? 1); - $sort = $request->query('sort'); - $direction = $request->query('dir') === 'desc' ? 'desc' : 'asc'; - $search = $request->query('q'); - - $perPage = $request->query('size'); $filters = $this->filters($request); + $schema = $this->data->schema($slug); - $listing = $this->data->list( - $slug, - max(1, $page), - is_string($perPage) && ctype_digit($perPage) ? (int) $perPage : null, - is_string($sort) && $sort !== '' ? $sort : null, - $direction, - is_string($search) && $search !== '' ? $search : null, - $filters, + $query = ListingQuery::fromRequest( + $request, + $this->settings->table, + $this->settings->url('data'), + $schema?->sortable() ?? [], + defaultSort: $schema?->identifier, + carried: ['resource' => $slug, ...DataFilter::toParameters($filters)], ); + $listing = $this->data->list($slug, $query->page, $query->size, $query->sort, $query->direction, $query->search, $filters); + + // TWO BOUNDS IN SERIES, AND THE PAGER FOLLOWS THE TIGHTER ONE. `firefly.admin.data.max-page-size` + // is the browser's own cap and can be smaller than the table's offered set, because a size that is + // merely large on an actuator payload materialises a whole table into PHP memory on a repository + // that cannot page. Whatever it settled on is the size the rows were actually served at, so the + // query is re-stated at it before anything computes a page number from it. + $query = $query->sized($listing->perPage); + + // ONE RE-QUERY, AND ONLY PAST THE END. The in-memory listings clamp `?page=999` onto the last page + // because they know the total before they slice; SQL does not, so a hand-edited page number comes + // back as an empty slice with a real total. Rather than show an empty table with a pager under it, + // the last page is fetched — which costs one extra query in a case that only a hand-edited URL or a + // stale bookmark reaches, and never on a click, because every link this page emits is in range. + $last = ListingPage::lastPageFor($listing->total, $query->size); + if ($listing->rows === [] && $listing->total > 0 && $query->page > $last) { + $listing = $this->data->list($slug, $last, $query->size, $query->sort, $query->direction, $query->search, $filters); + } + return $this->html($this->render('data-list', [ 'listing' => $listing, + 'query' => $query, + 'slice' => ListingPage::sliced($listing->rows, $listing->total, $query), 'writable' => $this->data->isWritable(), 'relations' => $this->data->relationsFor($slug), 'operators' => DataFilter::operators(), diff --git a/packages/admin/tests/Data/DataStableSortTest.php b/packages/admin/tests/Data/DataStableSortTest.php new file mode 100644 index 00000000..2f6c57ab --- /dev/null +++ b/packages/admin/tests/Data/DataStableSortTest.php @@ -0,0 +1,153 @@ + every ORDER BY clause the browser sent to `$table` while the callback ran + */ +function orderClausesFor(string $table, Closure $listing): array +{ + DB::flushQueryLog(); + DB::enableQueryLog(); + + $listing(); + + $clauses = []; + foreach (DB::getQueryLog() as $entry) { + $sql = $entry['query']; + + // `from "
    —11——self_contained · 300s · PKCEself_contained · 300s · PKCEself_contained · 300s · PKCE · consentself_contained · 300s · PKCE · consentself_contained · 300sself_contained · 300s{{ $client['scopes'] ?: '—' }} {{ $client['redirects'] ?: '—' }} {{ $client['issuance'] }}{{ $client['active'] }}{{ $client['active'] !== '' ? $client['active'] : '—' }}
    {{ $exchange['timestamp'] > 0 ? Format::since($exchange['timestamp'], $now) : '—' }} 0) title="{{ Format::instant($exchange['timestamp']) }}"@endif>{{ $exchange['timestamp'] > 0 ? Format::since($exchange['timestamp'], $now) : '—' }} {{ $exchange['method'] }} {{ $exchange['path'] }} {{ $exchange['status'] ?: '—' }}
    ]*>(.*?)
    '.$stamp.''); }); it('pages the OAuth2 clients and keeps the process-local caveat', function () { diff --git a/packages/admin/tests/FormatStampTest.php b/packages/admin/tests/FormatStampTest.php new file mode 100644 index 00000000..f9bb39c5 --- /dev/null +++ b/packages/admin/tests/FormatStampTest.php @@ -0,0 +1,64 @@ +toBe('just now') + ->and(Format::since($now - 30, $now))->toBe('30s ago') + ->and(Format::since($now - 300, $now))->toBe('5m ago') + ->and(Format::since($now - 7200, $now))->toBe('2h ago') + // The last age the formatter gives, and the first stamp. 86400 is the boundary the HTTP traffic + // page crosses whenever the ring — which is cache-backed on purpose, so it outlives the process — + // still holds a request from yesterday. + ->and(Format::since($now - 86399, $now))->toBe('23h ago') + ->and(Format::since($now - 86401, $now))->toBe(date('Y-m-d H:i', (int) ($now - 86401))); +}); + +it('never emits an age wider than eight characters, nor a stamp wider than sixteen', function () { + $now = 1_800_000_000.0; + $ages = [0, 1, 2, 9, 59, 60, 599, 3599, 3600, 35999, 86399]; + $stamps = [86401, 200_000, 9_000_000, 900_000_000]; + + foreach ($ages as $secondsAgo) { + expect(strlen(Format::since($now - $secondsAgo, $now)))->toBeLessThanOrEqual(8); + } + + // Sixteen is the number `AdminAction::data()` declares the When column from — `ch: 16` — and it is the + // number that has to move first if an arm ever renders something longer. + foreach ($stamps as $secondsAgo) { + expect(strlen(Format::since($now - $secondsAgo, $now)))->toBe(16); + } +}); + +/** + * The instant is the full nineteen characters `TableColumn::stamp()` takes as its DEFAULT width, and it is + * what a stamp cell carries on its title: an age is lossy in both directions — `2h ago` does not say which + * two hours, and the dated arm rounds the seconds off — so a reader correlating an exchange against a log + * line has somewhere to read the whole thing from, exactly as `t-token`, `t-path` and `t-line` do. + */ +it('spells the instant out in full for the title, seconds included', function () { + $at = 1_800_000_000.0; + + expect(Format::instant($at))->toBe(date('Y-m-d H:i:s', (int) $at)) + ->and(strlen(Format::instant($at)))->toBe(19) + // The title is strictly more than the cell: the same sixteen characters the dated arm renders, + // plus the three of the seconds it drops. + ->and(substr(Format::instant($at), 0, 16))->toBe(Format::since($at, $at + 200_000)); +}); diff --git a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php index f9e6e438..8fd7c638 100644 --- a/packages/admin/tests/Support/AdminTableCapstoneTestCase.php +++ b/packages/admin/tests/Support/AdminTableCapstoneTestCase.php @@ -4,6 +4,7 @@ namespace Firefly\Admin\Tests\Support; +use DateTimeImmutable; use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Admin\Tests\Support\Fixtures\BillingProperties; use Firefly\Admin\Tests\Support\Fixtures\HttpExchangesEndpointStub; @@ -75,6 +76,15 @@ */ abstract class AdminTableCapstoneTestCase extends AdminCapstoneTestCase { + /** + * The one exchange in the ring whose age is an instant rather than an offset — see `exchanges()`. + * + * A date in the past, never a relative one, because the branch it exists to reach is the branch that + * fires once an exchange is older than a day: a relative age has to be re-chosen every time somebody + * decides the fixture is "old enough", and a past date only ever gets older. + */ + protected const string ARCHIVED_EXCHANGE_AT = '2026-09-21T06:14:09.000000Z'; + protected function defineFireflyEnvironment(Application $app): void { parent::defineFireflyEnvironment($app); @@ -206,11 +216,19 @@ protected function meters(): array /** * The ring the HTTP traffic page lists, newest first and in the endpoint's own row shape. * - * The ages are RELATIVE to the moment the case boots, because the When column renders through - * Format::since: a fixed instant would drift past its 24-hour boundary and start rendering as an ISO - * date instead of an age, which is a fixture that changes its own meaning with the calendar. The four - * rows cover the three status bands the view colours (`ok`, `warn`, `err`) and give the Method column - * something to order by other than its own default. + * FOUR OF THE FIVE AGES ARE RELATIVE to the moment the case boots, because the When column renders + * through Format::since: a fixed instant would drift past its 24-hour boundary and start rendering as + * a dated stamp instead of an age, which is a fixture that changes its own meaning with the calendar. + * Those four cover the three status bands the view colours (`ok`, `warn`, `err`) and give the Method + * column something to order by other than its own default. + * + * THE FIFTH IS FIXED, AND IT IS FIXED FOR THE SAME REASON THE OTHERS ARE NOT. Format::since has a + * fifth arm nothing relative can hold still on: past 86400 seconds it stops giving an age and gives + * `2026-09-22 20:49`, which is SIXTEEN characters where the widest age is eight — and that is the + * alphabet the When column is sized from. A fixture whose oldest row was two hours old rendered + * `2h ago`, one arm short of the branch that sizes the column, so the column could be declared four + * characters too narrow and nothing failed. A fixed past instant is always past the boundary and only + * gets further from it, so this row renders the dated arm on every run, forever. * * @return list> */ @@ -228,9 +246,24 @@ protected function exchanges(): array 'correlationId' => '2c7d4e3a-5b6c-4d8e-9f0a-4d5e6f7a8b99', 'traceId' => '6df14f5799d56fc8c5ef14bf2f2f6958'], ['timestamp' => $at(7200), 'method' => 'POST', 'uri' => '/orders', 'status' => 500, 'durationMs' => 91.7, 'correlationId' => '3d6e5f4b-4c7d-4e9f-8a1b-5e6f7a8b9c00', 'traceId' => '7ef25f68aae67fd9d6ff25cf3f3f7a69'], + ['timestamp' => self::ARCHIVED_EXCHANGE_AT, 'method' => 'PUT', 'uri' => '/orders/{id}', 'status' => 503, 'durationMs' => 1204.6, + 'correlationId' => '4e5f6a7b-3d8e-4f0a-9b2c-6f7a8b9c0d11', 'traceId' => '8fa36a79bbf78ae0e7aa36da4a4a8b7a'], ]; } + /** + * The epoch seconds of ARCHIVED_EXCHANGE_AT, for a test that has to predict what the When cell drew. + * + * Public, like `seedOrders()`, because it is something a test BODY asks the case for and not a hook a + * subclass overrides. It hands back epoch seconds rather than the rendered string so the test formats + * it the way the page does — through the process's own timezone — instead of pinning a literal that + * would be right only where `app.timezone` happens to be UTC. + */ + public function archivedExchangeEpoch(): int + { + return (int) (new DateTimeImmutable(self::ARCHIVED_EXCHANGE_AT))->format('U'); + } + /** * The registered OAuth2 clients, in OAuth2ClientsEndpoint's row shape. * diff --git a/tests/Browser/ObservabilityTest.php b/tests/Browser/ObservabilityTest.php index a7c099e8..b46cf4bc 100644 --- a/tests/Browser/ObservabilityTest.php +++ b/tests/Browser/ObservabilityTest.php @@ -40,6 +40,59 @@ ->screenshot(filename: 'observability-http-traffic'); }); +/** + * THE WHEN COLUMN HAS TO HOLD THE WIDEST THING ITS FORMATTER CAN PUT IN IT, and on a browser suite every + * request was served seconds ago — so the page under test never contains that string. + * + * Format::since gives an age for the first twenty-four hours and then gives up and renders the instant: + * `2026-09-22 20:49`, sixteen characters where `2h ago` is six. The exchange ring is cache-backed on + * purpose — the traffic page's own empty state tells the operator to configure it that way, so that the + * buffer survives a PHP-FPM request — which means a low-traffic or freshly-idle application routinely + * lists yesterday's requests. The column was declared twelve characters wide: a 118px box whose text + * starts after the 14px left padding and runs 115px, so `table.ftable td{overflow:hidden}` took the last + * glyph and a half off and the operator read `2026-09-22 20:` as if it were the whole value. + * + * THE SCENARIO SEEDS ITS OWN WORST CASE, exactly as the Environment and Health guards in + * tests/Browser/AdminTablesTest.php do, and for the same reason they give: what is under test is whether + * the DECLARED width covers the formatter's whole alphabet, so the measurement needs that alphabet in the + * cell rather than whatever age this run happens to have produced. Everything around the substitution is + * real — the colgroup the page emitted, the stylesheet it inlined, the density the deployment configured. + */ +it('holds a dated stamp in the When column without clipping it', function (): void { + /** @var TracedBrowserTestCase $this */ + visit('/greetings/Ada')->assertSourceHas('Hello, Ada!'); + + visit('/firefly/http') + ->assertScript(<<<'JS' + (() => { + const cell = document.querySelector('#http-body td.t-stamp'); + if (cell === null) { return 'no exchange rows on the page'; } + + // The instant is on the title whatever the cell drew, because an age is lossy both ways: + // `2h ago` does not say which two hours, and the dated arm rounds the seconds off. + const title = cell.getAttribute('title'); + if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(title || '')) { + return 'the stamp cell carries no instant on its title: ' + title; + } + + // And it degrades the way every other clipped kind does if it ever outgrows the column + // again — an ellipsis says "there was more", a mid-glyph cut says nothing at all. + const overflow = getComputedStyle(cell).textOverflow; + if (overflow !== 'ellipsis') { return 'td.t-stamp text-overflow is ' + overflow; } + + // The measurement: the widest string Format::since has, in the column as this page laid + // it out. Restored afterwards so the screenshot above still shows the real page. + const drawn = cell.textContent; + cell.textContent = '2026-09-22 20:49'; + const clipped = cell.scrollWidth - cell.clientWidth; + cell.textContent = drawn; + + return clipped <= 0 || 'the When column clips a dated stamp by ' + clipped + 'px'; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); + it('serves the exchanges as JSON with a traceId on each row', function (): void { /** @var TracedBrowserTestCase $this */ visit('/greetings/Ada')->assertSourceHas('Hello, Ada!'); From 20a273aeb701ed86883a4df645126421df81153d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:32:30 -0700 Subject: [PATCH 45/78] fix(web): the degraded problem document keeps every member that cannot be the cause, and the pipeline proves it answers at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal fallback pinned category and severity to Internal/Error for every exception while keeping the real status, title and code, so a 409 came back saying `internal` and a 422 — reachable through FieldError::$rejectedValue, which is `mixed` — told a generated client branching on `category == 'validation'` to render the wrong arm. It also dropped detail, traceId, correlationId, instance and timestamp, which ErrorResponse declares `?string` and array_diff_key() keeps an extension from shadowing: none of them can be why json_encode refused, and dropping them published exactly the document whose body a person cannot correlate, against this class's own promise, and withheld an authored sub-500 sentence the disclosure gate had already cleared. Every standard member is now carried through a type check, the enums re-derived with tryFrom() so the published constraint holds; `errors` and the extension members, which genuinely can be the cause, still go. The behaviour was proven only by unit tests calling render() directly, where a throw out of the renderer is observable but the blank 500 it causes is not — that happens one layer up. Two fixture routes on ConflictController and two capstone cases now drive a latin-1 byte and an unencodable extension member through the real kernel; against the previous encoder both fail with 500 and the JsonException escaping, which is the claim. --- book/src-es/04-first-http-api.md | 2 +- book/src/04-first-http-api.md | 2 +- .../src/Exception/ProblemDetailsRenderer.php | 82 +++++++++++++++---- .../web/tests/CapstoneWebIntegrationTest.php | 48 +++++++++++ .../Exception/ProblemDetailsRendererTest.php | 42 +++++++++- .../Fixtures/Advice/ConflictController.php | 30 +++++++ 6 files changed, 184 insertions(+), 22 deletions(-) diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index a4d0aa8c..c476c83a 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -462,7 +462,7 @@ final class ProblemDetailsRenderer El corte que hay encima del `return` oculta el resto del trabajo con cabeceras: las cabeceras que una `HttpExceptionInterface` ya trae se copian sobre la respuesta — el `Allow` de un `405`, entre ellas — y un `503` gana además `Retry-After: 5`. -`self::encode()` es el cuerpo, y es total a propósito: un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse, y cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo que lleva el estado, el código y una frase opaca. Un renderizador que lanzara aquí fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. +`self::encode()` es el cuerpo, y es total a propósito: un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse, y cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo. Ese documento conserva todos los miembros que la forma de arriba define — el estado, el título, el código, la categoría, la severidad, la frase y los identificadores de referencia — porque cada uno es una cadena que escribió esta clase y ninguno puede ser lo que `json_encode` rechazó; lo que descarta es lo que sí puede serlo: los miembros de extensión y los errores de campo. Un renderizador que lanzara aquí fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se convierte un throwable cualquiera. Esa regla vive una clase más allá, en `Firefly\Web\Error\ProblemMapper`, porque la página de error HTML del Capítulo 10 necesita la respuesta idéntica y dos copias de ella acabarían dándole a un navegador y a un cliente de API códigos distintos para el mismo fallo: diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md index 654356a4..fab22490 100644 --- a/book/src/04-first-http-api.md +++ b/book/src/04-first-http-api.md @@ -462,7 +462,7 @@ final class ProblemDetailsRenderer The cut above the `return` hides the rest of the header work: the headers an `HttpExceptionInterface` already carries are copied onto the response — a `405`'s `Allow` among them — and a `503` additionally gains `Retry-After: 5`. -`self::encode()` is the body, and it is total on purpose: a byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised, and anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document carrying the status, the code and an opaque sentence. A renderer that threw here would fail while handling the failure, and the caller would receive no document at all. +`self::encode()` is the body, and it is total on purpose: a byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised, and anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document. That fallback keeps every member the shape above defines — the status, the title, the code, the category, the severity, the sentence and the reference ids — because each of them is a string this class wrote and none of them can be what `json_encode` refused; what it drops is what can be, the extension members and the field errors. A renderer that threw here would fail while handling the failure, and the caller would receive no document at all. What `render()` deliberately does **not** decide is which `FireflyException` an arbitrary throwable becomes. That rule lives one class along, in `Firefly\Web\Error\ProblemMapper`, because the HTML error page of Chapter 10 needs the identical answer and two copies of it would eventually hand a browser and an API client different codes for the same failure: diff --git a/packages/web/src/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index 9256ed20..07ec8f5f 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -47,9 +47,10 @@ * `X-Correlation-Id` untouched and gains `correlationId`, and the trace id is echoed on its own header * (`firefly.web.trace-id.header`, `X-Trace-Id` by default, '' to disable) only when there is one. * - A BODY, unconditionally. The encoder is total: an invalid UTF-8 byte is substituted rather than - * raised, and anything json_encode still refuses falls back to a minimal document. This method used to - * throw JsonException out of the error handler on a latin-1 byte in a driver message, which turned a - * described failure into a blank 500 with no document at all. + * raised, and anything json_encode still refuses falls back to a minimal document — one that keeps every + * standard member, the reference among them, and drops only the values that can be the cause. This + * method used to throw JsonException out of the error handler on a latin-1 byte in a driver message, + * which turned a described failure into a blank 500 with no document at all. */ final class ProblemDetailsRenderer { @@ -147,32 +148,79 @@ private static function encode(array $payload): string } /** - * A document that CANNOT fail to encode: six members, each rebuilt from a value whose type is checked - * here rather than trusted, with every string passing through the same substitution. + * A document that CANNOT fail to encode: the standard members ErrorResponse declares, each rebuilt from + * a value whose type is checked here rather than trusted, with every string passing through the same + * substitution. * - * The status and the code are kept when they are what they claim to be, because they are the two members - * a client branches on; everything an application contributed — every extension member, the detail, the - * validation errors — is dropped, since one of them is why we are here. The literal at the bottom is - * unreachable and is written anyway: a renderer on the error path does not get to assume. + * WHAT IS DROPPED IS WHAT COULD BE THE CAUSE, AND NOTHING ELSE. Only a NON-string reaches this branch — + * JSON_INVALID_UTF8_SUBSTITUTE has already answered every bad byte — so what brought us here is an + * extension member an application chose at the throw site, a FieldError::$rejectedValue (`mixed`, so + * literally whatever the client sent), or a structure too deep or too circular to walk. `errors` and the + * open namespace therefore go. The standard members STAY, because ErrorResponse declares every one of + * them `?string` (`int`, for the status) and array_diff_key() keeps a same-named extension out of the + * document, so not one of them can be why we are here and dropping them buys nothing: + * + * - `category` and `severity` pinned to Internal/Error for every exception publishes a document that + * contradicts its own status and code — a 409 whose category reads `internal`, a 422 a generated + * client branching on `category == 'validation'` renders down the wrong arm. They are re-derived + * through tryFrom() rather than copied so the published schema's enum constraint holds whatever the + * payload turns out to say. + * - `detail`, `traceId` and `correlationId` dropped publishes exactly the document whose body a person + * cannot correlate, against this class's promise that quoting the reference is possible from the body + * alone — and replaces an authored sub-500 sentence that ProblemMapper's disclosure gate had already + * cleared for publication with an opaque internal one that withholds nothing it had not already let + * through. + * + * The literal at the bottom is unreachable and is written anyway: a renderer on the error path does not + * get to assume. * * @param array $payload */ private static function minimal(array $payload): string { $status = is_int($payload['status'] ?? null) ? $payload['status'] : 500; - $code = is_string($payload['code'] ?? null) ? $payload['code'] : 'INTERNAL_ERROR'; + $category = ErrorCategory::tryFrom(self::member($payload, 'category', '')) ?? ErrorCategory::Internal; + $severity = ErrorSeverity::tryFrom(self::member($payload, 'severity', '')) ?? ErrorSeverity::Error; - $json = json_encode([ + $document = [ 'status' => $status, - 'title' => ErrorResponse::titleFor($status), - 'code' => $code, - 'category' => ErrorCategory::Internal->value, - 'severity' => ErrorSeverity::Error->value, - 'detail' => ProblemMapper::OPAQUE, - ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + 'title' => self::member($payload, 'title', ErrorResponse::titleFor($status)), + 'code' => self::member($payload, 'code', 'INTERNAL_ERROR'), + 'category' => $category->value, + 'severity' => $severity->value, + 'detail' => self::member($payload, 'detail', ProblemMapper::OPAQUE), + ]; + + // The rest are optional in the DOCUMENT as well as on the DTO — toArray() writes each one only when + // it is non-null — so an absent member stays absent here rather than becoming an invented empty + // string. The order is STANDARD_MEMBERS', so the degraded document reads like the full one. + foreach (['type', 'instance', 'traceId', 'correlationId', 'timestamp'] as $member) { + $value = $payload[$member] ?? null; + + if (is_string($value)) { + $document[$member] = $value; + } + } + + $json = json_encode($document, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); return is_string($json) ? $json : '{"status":500,"title":"Internal Server Error","code":"INTERNAL_ERROR","category":"internal","severity":"error"}'; } + + /** + * One standard member of the payload, when it is the string ErrorResponse declares it to be. + * + * The type check is not ceremony: minimal() runs because something in this payload was not what the + * document's shape says it is, and a member read on trust here would take the fallback down with it. + * + * @param array $payload + */ + private static function member(array $payload, string $name, string $fallback): string + { + $value = $payload[$name] ?? null; + + return is_string($value) ? $value : $fallback; + } } diff --git a/packages/web/tests/CapstoneWebIntegrationTest.php b/packages/web/tests/CapstoneWebIntegrationTest.php index a3eaf0e0..635adffb 100644 --- a/packages/web/tests/CapstoneWebIntegrationTest.php +++ b/packages/web/tests/CapstoneWebIntegrationTest.php @@ -169,3 +169,51 @@ expect((string) $response->getContent())->not->toContain('Enums') ->and((string) $response->getContent())->not->toContain('Backed Enum'); }); + +/* + * A BYTE THAT IS NOT UTF-8 USED TO COST THE WHOLE DOCUMENT. The renderer encoded with JSON_THROW_ON_ERROR, + * so one latin-1 byte anywhere in the payload raised a JsonException OUT of the error handler and the + * caller received a blank 500 from the web server with nothing in it. Both cases below are driven through + * the real kernel for that reason: the blank 500 is not something the renderer returns, it is what the + * layer above it does with the exception the renderer threw, and a test that calls render() directly can + * only ever observe the throw. + */ +it('answers a controller that threw with a non-UTF-8 byte with a problem document, not a blank 500', function () { + /** @var WebCapstoneTestCase $this */ + $response = $this->getJson('/errors/latin-1'); + + $response->assertStatus(409) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'LEDGER_CONFLICT') + ->assertJsonPath('category', 'business'); + + // Decodable, and still the sentence it was built from: the byte was substituted, not the document lost. + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + expect($payload['detail'])->toContain('The ledger for') + ->and($payload['detail'])->toContain('disagrees.') + ->and($payload['traceId'])->toBe($response->headers->get('X-Correlation-Id')); +}); + +it('answers a member json_encode refuses with the minimal document, still describing the failure it was built for', function () { + /** @var WebCapstoneTestCase $this */ + // INF in an extension member is what substitution cannot answer, so this is the fallback on the wire. + $response = $this->getJson('/errors/unencodable'); + + $response->assertStatus(409) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('status', 409) + ->assertJsonPath('title', 'Conflict') + ->assertJsonPath('code', 'LEDGER_CONFLICT') + // Degraded, not contradictory: a 409 whose category read `internal` would have a client branching + // on the status and a client branching on the category disagreeing about the same document. + ->assertJsonPath('category', 'business') + ->assertJsonPath('severity', 'warning') + ->assertJsonPath('detail', 'The ledger disagrees.') + ->assertJsonMissingPath('ratio'); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + expect($payload['traceId'])->toBe($response->headers->get('X-Correlation-Id')) + ->and($payload['correlationId'])->toBe($response->headers->get('X-Correlation-Id')); +}); diff --git a/packages/web/tests/Exception/ProblemDetailsRendererTest.php b/packages/web/tests/Exception/ProblemDetailsRendererTest.php index 6df3c0d0..422db8b3 100644 --- a/packages/web/tests/Exception/ProblemDetailsRendererTest.php +++ b/packages/web/tests/Exception/ProblemDetailsRendererTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use Firefly\Config\Config; +use Firefly\Kernel\Error\FieldError; use Firefly\Kernel\Exception\Business\ConflictException; use Firefly\Kernel\Exception\Business\PaymentRequiredException; use Firefly\Kernel\Exception\Business\ResourceNotFoundException; +use Firefly\Kernel\Exception\Business\ValidationException; use Firefly\Kernel\Exception\Infrastructure\ServiceUnavailableException; use Firefly\Web\Error\ErrorPageSettings; use Firefly\Web\Error\ProblemMapper; @@ -304,7 +306,10 @@ // extension member at the throw site. The document that comes back is smaller and still true. $impossible = (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['ratio' => INF]); - $response = (new ProblemDetailsRenderer)->render($impossible, Request::create('/api/x')); + $request = Request::create('/api/x'); + $request->headers->set(CorrelationIdFilter::HEADER, 'corr-77'); + + $response = (new ProblemDetailsRenderer)->render($impossible, $request); /** @var array $payload */ $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); @@ -313,12 +318,43 @@ ->and($payload['status'])->toBe(409) ->and($payload['title'])->toBe('Conflict') ->and($payload['code'])->toBe('LEDGER_CONFLICT') - ->and($payload['category'])->toBe('internal') - ->and($payload['detail'])->toBe(ProblemMapper::OPAQUE) + // SMALLER, NOT CONTRADICTORY. A document whose status and code say business conflict while its + // category says `internal` is one no client can branch on twice and get the same answer, and the + // real values are plain strings ErrorResponse wrote — they can never be why the encode failed. + ->and($payload['category'])->toBe('business') + ->and($payload['severity'])->toBe('warning') + // The authored sub-500 sentence survives too: ProblemMapper's disclosure gate had already cleared + // it, so replacing it with the opaque one would withhold nothing and lose everything. + ->and($payload['detail'])->toBe('The ledger disagrees.') + // And the degraded body is still correlatable, which is the one action it exists to make possible. + ->and($payload['traceId'])->toBe('corr-77') + ->and($payload['correlationId'])->toBe('corr-77') + ->and($payload['instance'])->toBe('api/x') + ->and($payload['timestamp'])->toBeString() // The member that could not be encoded is simply not there; it is not a reason to answer nothing. ->and($payload)->not->toHaveKey('ratio'); }); +it('keeps the category a generated client branches on when a rejected value is what cannot be encoded', function () { + // FieldError::$rejectedValue is `mixed` — literally whatever the client sent — so a 422 is a real route + // into the fallback, and a 422 that came back saying `category: internal` would send a generated client + // that branches on `category == 'validation'` to render field errors down the wrong arm. + $invalid = new ValidationException('Validation failed', [new FieldError('ratio', 'must be a number', rejectedValue: INF)]); + + $response = (new ProblemDetailsRenderer)->render($invalid, Request::create('/api/x')); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + expect($response->getStatusCode())->toBe(422) + ->and($payload['status'])->toBe(422) + ->and($payload['category'])->toBe('validation') + ->and($payload['code'])->toBe('VALIDATION_ERROR') + ->and($payload['detail'])->toBe('Validation failed') + // `errors` genuinely holds the value that could not be encoded, so it is the one member that goes. + ->and($payload)->not->toHaveKey('errors'); +}); + it('answers with a document even when the status itself is the only thing left', function () { // The last branch, exercised directly: whatever the payload holds, the renderer returns valid JSON. $response = (new ProblemDetailsRenderer)->render( diff --git a/packages/web/tests/Fixtures/Advice/ConflictController.php b/packages/web/tests/Fixtures/Advice/ConflictController.php index 253c3a15..90fd3fd3 100644 --- a/packages/web/tests/Fixtures/Advice/ConflictController.php +++ b/packages/web/tests/Fixtures/Advice/ConflictController.php @@ -4,6 +4,7 @@ namespace Firefly\Web\Tests\Fixtures\Advice; +use Firefly\Kernel\Exception\Business\ConflictException; use Firefly\Web\Attributes\ExceptionHandler; use Firefly\Web\Attributes\GetMapping; use Firefly\Web\Attributes\RequestMapping; @@ -27,6 +28,35 @@ public function another(): array throw new AnotherException('downstream refused'); } + /** + * A LATIN-1 BYTE IN THE SENTENCE, thrown from a controller through the whole kernel — the shape a driver + * message quoting a non-UTF-8 column arrives in. json_encode with JSON_THROW_ON_ERROR raised on this + * byte from inside the problem renderer, i.e. from inside the handler already handling the failure, and + * what reached the client was not a worse document but NO document: a blank 500 from the web server. + * Only the real pipeline can falsify that, because the blank 500 is what the layer ABOVE the renderer + * does with the exception the renderer threw. + * + * @return array + */ + #[GetMapping('/latin-1')] + public function latin1(): array + { + throw new ConflictException("The ledger for caf\xE9 disagrees.", 'LEDGER_CONFLICT'); + } + + /** + * The same route into the renderer, one step further: an extension member holding a value json_encode + * refuses outright (INF), which substitution cannot answer and the minimal document must. Through the + * pipeline rather than at the renderer's own front door, so the assertion is the one a caller can make. + * + * @return array + */ + #[GetMapping('/unencodable')] + public function unencodable(): array + { + throw (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['ratio' => INF]); + } + /** * Controller-LOCAL handler — beats any global #[ControllerAdvice] for CustomBusinessException. Its return * value renders at the exception's httpStatus() (409), not the route's 200 (exercises the matched-handler From 58ea54f9ad387844ee332c9750b60f8478d9d4ff Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:43:36 -0700 Subject: [PATCH 46/78] fix(web): make the problem renderer total against a throwing accessor, and keep the field errors it used to drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encoder caught JsonException, which is only the half of the failure set json_encode REPORTS. Encoding an object CALLS the application's own code — JsonSerializable::jsonSerialize(), an Eloquent accessor, a decrypting cast — and whatever that code raises comes out of json_encode with no error state set and no JsonException in it, so it escaped render() untouched. Extension members are `mixed` and chosen at the throw site by withExtensions(), and FieldError::$rejectedValue is `mixed` too, so a model whose accessor reads the database is an ordinary member here — and it is likeliest to throw when the database is the thing that already failed, which is why the renderer was running at all. The result was exactly the blank 500 this work exists to remove, against a class docblock and a book paragraph that had just promised a body unconditionally. The catch is widened to Throwable and JsonException dropped from the imports. minimal() also dropped the whole `errors` array when only one of its members can ever be the cause: FieldError declares field and message `string` and code/constraint `?string`, so `rejectedValue` alone is `mixed`. It is the client's own value — the validators fill it with Arr::get($data, $field) off the decoded body, and json_decode('{"ratio": 1e999}') is INF — so any caller could post one number into a #[Valid] body and get back a 422 that still said `category: validation` while carrying no `errors` member at all, sending a generated client down the field-error arm with nothing to render and taking every other field's sentence with it. fieldErrors() now rebuilds the member from the declared strings and drops only the rejected values, keeping `errors` last where STANDARD_MEMBERS has it. Covered by two renderer tests and a capstone route through the real kernel, since a blank 500 is what the layer above does with the exception the renderer threw and a direct call can only observe the throw. All three fail against the previous renderer. The book's English and Spanish paragraphs are corrected on both points, as the docs guard pins them to this file. --- book/src-es/04-first-http-api.md | 2 +- book/src/04-first-http-api.md | 2 +- .../src/Exception/ProblemDetailsRenderer.php | 113 +++++++++++++++--- .../web/tests/CapstoneWebIntegrationTest.php | 20 ++++ .../Exception/ProblemDetailsRendererTest.php | 68 +++++++++-- .../Fixtures/Advice/ConflictController.php | 28 +++++ 6 files changed, 208 insertions(+), 25 deletions(-) diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index c476c83a..2b35f09f 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -462,7 +462,7 @@ final class ProblemDetailsRenderer El corte que hay encima del `return` oculta el resto del trabajo con cabeceras: las cabeceras que una `HttpExceptionInterface` ya trae se copian sobre la respuesta — el `Allow` de un `405`, entre ellas — y un `503` gana además `Retry-After: 5`. -`self::encode()` es el cuerpo, y es total a propósito: un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse, y cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo. Ese documento conserva todos los miembros que la forma de arriba define — el estado, el título, el código, la categoría, la severidad, la frase y los identificadores de referencia — porque cada uno es una cadena que escribió esta clase y ninguno puede ser lo que `json_encode` rechazó; lo que descarta es lo que sí puede serlo: los miembros de extensión y los errores de campo. Un renderizador que lanzara aquí fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. +`self::encode()` es el cuerpo, y es total a propósito. Un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse. Cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo; y también cae ahí cualquier cosa que lance el código *propio* de un objeto codificado, porque codificar un objeto llama a ese código — un `jsonSerialize()`, un accesor de Eloquent — y lo que lance no llega a ser una `JsonException` siquiera, razón por la cual el respaldo captura `Throwable` y no sólo la de JSON. Ese documento conserva todos los miembros que la forma de arriba define — el estado, el título, el código, la categoría, la severidad, la frase y los identificadores de referencia — porque cada uno es una cadena que escribió esta clase y ninguno puede ser lo que `json_encode` rechazó. Lo que descarta es lo que sí puede serlo, y sólo eso: los miembros de extensión, que son `mixed` sin excepción, y el `rejectedValue` de cada error de campo. Los errores de campo en sí se quedan, con su nombre y su frase intactos — un `422` que siguiera diciendo `category: validation` sin llevar miembro `errors` mandaría a un cliente generado a la rama de errores de campo sin nada que pintar. Un renderizador que lanzara aquí fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se convierte un throwable cualquiera. Esa regla vive una clase más allá, en `Firefly\Web\Error\ProblemMapper`, porque la página de error HTML del Capítulo 10 necesita la respuesta idéntica y dos copias de ella acabarían dándole a un navegador y a un cliente de API códigos distintos para el mismo fallo: diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md index fab22490..c585db18 100644 --- a/book/src/04-first-http-api.md +++ b/book/src/04-first-http-api.md @@ -462,7 +462,7 @@ final class ProblemDetailsRenderer The cut above the `return` hides the rest of the header work: the headers an `HttpExceptionInterface` already carries are copied onto the response — a `405`'s `Allow` among them — and a `503` additionally gains `Retry-After: 5`. -`self::encode()` is the body, and it is total on purpose: a byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised, and anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document. That fallback keeps every member the shape above defines — the status, the title, the code, the category, the severity, the sentence and the reference ids — because each of them is a string this class wrote and none of them can be what `json_encode` refused; what it drops is what can be, the extension members and the field errors. A renderer that threw here would fail while handling the failure, and the caller would receive no document at all. +`self::encode()` is the body, and it is total on purpose. A byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised. Anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document; so does anything an encoded object's *own* code throws, because encoding an object calls that code — a `jsonSerialize()`, an Eloquent accessor — and what it raises never becomes a `JsonException` at all, which is why the fallback catches `Throwable` and not just the JSON one. That fallback keeps every member the shape above defines — the status, the title, the code, the category, the severity, the sentence and the reference ids — because each of them is a string this class wrote and none of them can be what `json_encode` refused. What it drops is what can be, and only that: the extension members, which are `mixed` to a one, and each field error's `rejectedValue`. The field errors themselves stay, names and sentences intact — a `422` that still says `category: validation` while carrying no `errors` member would send a generated client down the field-error arm with nothing to render. A renderer that threw here would fail while handling the failure, and the caller would receive no document at all. What `render()` deliberately does **not** decide is which `FireflyException` an arbitrary throwable becomes. That rule lives one class along, in `Firefly\Web\Error\ProblemMapper`, because the HTML error page of Chapter 10 needs the identical answer and two copies of it would eventually hand a browser and an API client different codes for the same failure: diff --git a/packages/web/src/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index 07ec8f5f..44517849 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -15,7 +15,6 @@ use Firefly\Web\Trace\TraceContext; use Illuminate\Http\Request; use Illuminate\Http\Response; -use JsonException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; @@ -47,10 +46,12 @@ * `X-Correlation-Id` untouched and gains `correlationId`, and the trace id is echoed on its own header * (`firefly.web.trace-id.header`, `X-Trace-Id` by default, '' to disable) only when there is one. * - A BODY, unconditionally. The encoder is total: an invalid UTF-8 byte is substituted rather than - * raised, and anything json_encode still refuses falls back to a minimal document — one that keeps every - * standard member, the reference among them, and drops only the values that can be the cause. This - * method used to throw JsonException out of the error handler on a latin-1 byte in a driver message, - * which turned a described failure into a blank 500 with no document at all. + * raised, and anything json_encode still refuses — or anything an encoded object's OWN jsonSerialize() + * or accessor throws, which JSON_THROW_ON_ERROR never sees and which catching JsonException therefore + * never caught — falls back to a minimal document. That document keeps every standard member, the + * reference among them, and every field error's name and sentence, and drops only the values that can + * be the cause. This method used to throw JsonException out of the error handler on a latin-1 byte in + * a driver message, which turned a described failure into a blank 500 with no document at all. */ final class ProblemDetailsRenderer { @@ -130,9 +131,22 @@ public function render(Throwable $e, Request $request): Response * file name off a filesystem that is not UTF-8. * * JSON_INVALID_UTF8_SUBSTITUTE answers that case properly — the offending bytes become U+FFFD and the - * document is still the document. The try/catch is the belt to that pair of braces: recursion, a - * resource, an INF or NAN an application put in an extension member are all things json_encode still - * refuses, and none of them is a reason to answer a caller with nothing. + * document is still the document. The try/catch is the belt to that pair of braces, and it catches + * THROWABLE RATHER THAN JsonException BECAUSE JSON_THROW_ON_ERROR DOES NOT COVER THE WHOLE FAILURE SET. + * Two different kinds of thing end up here: + * + * - What json_encode refuses AND reports — recursion, a resource, an INF or NAN an application put in + * an extension member. This is the arm JSON_THROW_ON_ERROR turns into a JsonException. + * - What json_encode never sees as an error at all. Encoding an object CALLS the application's own + * code — JsonSerializable::jsonSerialize(), an Eloquent accessor, a decrypting cast — and whatever + * that code raises propagates straight out of json_encode with no error state set and no + * JsonException anywhere in it. Catching JsonException left this arm open. + * + * The second arm is ordinary, not exotic, and it is worst exactly here: extension members are `mixed` + * and are chosen at the throw site by withExtensions(), FieldError::$rejectedValue is `mixed` too, and + * an object whose accessor reads the database is most likely to throw when the database is the thing + * that already failed — which is to say, while this renderer is describing that failure. Neither arm is + * a reason to answer a caller with nothing. * * @param array $payload */ @@ -140,7 +154,7 @@ private static function encode(array $payload): string { try { $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); - } catch (JsonException) { + } catch (Throwable) { $json = false; } @@ -152,19 +166,23 @@ private static function encode(array $payload): string * a value whose type is checked here rather than trusted, with every string passing through the same * substitution. * - * WHAT IS DROPPED IS WHAT COULD BE THE CAUSE, AND NOTHING ELSE. Only a NON-string reaches this branch — - * JSON_INVALID_UTF8_SUBSTITUTE has already answered every bad byte — so what brought us here is an - * extension member an application chose at the throw site, a FieldError::$rejectedValue (`mixed`, so - * literally whatever the client sent), or a structure too deep or too circular to walk. `errors` and the - * open namespace therefore go. The standard members STAY, because ErrorResponse declares every one of - * them `?string` (`int`, for the status) and array_diff_key() keeps a same-named extension out of the + * WHAT IS DROPPED IS WHAT COULD BE THE CAUSE, AND NOTHING ELSE — down to the MEMBER, not the array that + * happens to hold it. Only a NON-string reaches this branch — JSON_INVALID_UTF8_SUBSTITUTE has already + * answered every bad byte — so what brought us here is an extension member an application chose at the + * throw site, a FieldError::$rejectedValue (`mixed`, so literally whatever the client sent), a structure + * too deep or too circular to walk, or an object whose own accessor threw mid-encode. The open namespace + * therefore goes WHOLE, because every one of its members is `mixed` and any of them could be the one. + * `errors` does NOT go whole: exactly one of its members is `mixed`, and fieldErrors() takes that one + * out and keeps the rest. The standard members STAY, because ErrorResponse declares every one of them + * `?string` (`int`, for the status) and array_diff_key() keeps a same-named extension out of the * document, so not one of them can be why we are here and dropping them buys nothing: * * - `category` and `severity` pinned to Internal/Error for every exception publishes a document that * contradicts its own status and code — a 409 whose category reads `internal`, a 422 a generated * client branching on `category == 'validation'` renders down the wrong arm. They are re-derived * through tryFrom() rather than copied so the published schema's enum constraint holds whatever the - * payload turns out to say. + * payload turns out to say. Keeping `category: validation` is only half the promise, and + * fieldErrors() is the other half: the arm the client renders has to have something in it. * - `detail`, `traceId` and `correlationId` dropped publishes exactly the document whose body a person * cannot correlate, against this class's promise that quoting the reference is possible from the body * alone — and replaces an authored sub-500 sentence that ProblemMapper's disclosure gate had already @@ -202,6 +220,13 @@ private static function minimal(array $payload): string } } + // LAST, where STANDARD_MEMBERS has it, so the degraded document still reads like the full one. + $errors = self::fieldErrors($payload); + + if ($errors !== []) { + $document['errors'] = $errors; + } + $json = json_encode($document, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); return is_string($json) @@ -209,6 +234,62 @@ private static function minimal(array $payload): string : '{"status":500,"title":"Internal Server Error","code":"INTERNAL_ERROR","category":"internal","severity":"error"}'; } + /** + * The field errors of the payload, rebuilt from the members FieldError DECLARES to be strings. + * + * DROPPING `errors` WHOLESALE DROPPED FAR MORE THAN THE CAUSE. FieldError declares `$field` and + * `$message` as `string` and `$code`/`$constraint` as `?string`, so not one of those four can ever be + * what json_encode refused; `$rejectedValue` is the only `mixed` member, and it is the CLIENT'S own + * value — the validators fill it with `Arr::get($data, $field)` straight off the decoded request body, + * which is how a posted `{"ratio": 1e999}` arrives here as INF. So one caller posting one number used + * to get back a 422 that said `category: validation` and carried no `errors` member at all, taking + * every OTHER field's sentence down with it — a document that invites a generated client down the + * field-error arm and then hands that arm nothing. The rejected value goes. The field, the sentence, + * the code and the constraint stay. + * + * Every member is type-checked rather than trusted, for minimal()'s reason: we are on this path + * precisely because the payload was not the shape it claims to be. An entry that is not an array, or + * one that keeps no member at all, contributes nothing instead of contributing an empty object — and + * checking the type is also what keeps this total, since reading only strings out of the payload + * cannot re-enter the application code that may have thrown on the way in. + * + * @param array $payload + * @return list> + */ + private static function fieldErrors(array $payload): array + { + $entries = $payload['errors'] ?? null; + + if (! is_array($entries)) { + return []; + } + + $errors = []; + + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $kept = []; + + // FieldError::toArray()'s order, minus the one member that could be why we are here. + foreach (['field', 'message', 'code', 'constraint'] as $member) { + $value = $entry[$member] ?? null; + + if (is_string($value)) { + $kept[$member] = $value; + } + } + + if ($kept !== []) { + $errors[] = $kept; + } + } + + return $errors; + } + /** * One standard member of the payload, when it is the string ErrorResponse declares it to be. * diff --git a/packages/web/tests/CapstoneWebIntegrationTest.php b/packages/web/tests/CapstoneWebIntegrationTest.php index 635adffb..6c0b69da 100644 --- a/packages/web/tests/CapstoneWebIntegrationTest.php +++ b/packages/web/tests/CapstoneWebIntegrationTest.php @@ -217,3 +217,23 @@ expect($payload['traceId'])->toBe($response->headers->get('X-Correlation-Id')) ->and($payload['correlationId'])->toBe($response->headers->get('X-Correlation-Id')); }); + +it('answers an extension member whose own accessor throws with the minimal document, not a blank 500', function () { + /** @var WebCapstoneTestCase $this */ + // The failure json_encode does not REPORT, it merely propagates: encoding an object calls the + // application's code, so a jsonSerialize() or an Eloquent accessor that throws comes out of + // json_encode with no JsonException anywhere in it. `catch (JsonException)` let it escape render(), + // and the caller got the same blank 500 this whole task exists to remove. + $response = $this->getJson('/errors/throwing-extension'); + + $response->assertStatus(409) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('status', 409) + ->assertJsonPath('code', 'LEDGER_CONFLICT') + ->assertJsonPath('category', 'business') + ->assertJsonPath('detail', 'The ledger disagrees.') + ->assertJsonMissingPath('balance'); + + // The thrower's own sentence is an internal detail and must not ride out on the document either. + expect((string) $response->getContent())->not->toContain('accessor could not read'); +}); diff --git a/packages/web/tests/Exception/ProblemDetailsRendererTest.php b/packages/web/tests/Exception/ProblemDetailsRendererTest.php index 422db8b3..ac2e9f1c 100644 --- a/packages/web/tests/Exception/ProblemDetailsRendererTest.php +++ b/packages/web/tests/Exception/ProblemDetailsRendererTest.php @@ -335,11 +335,55 @@ ->and($payload)->not->toHaveKey('ratio'); }); -it('keeps the category a generated client branches on when a rejected value is what cannot be encoded', function () { - // FieldError::$rejectedValue is `mixed` — literally whatever the client sent — so a 422 is a real route - // into the fallback, and a 422 that came back saying `category: internal` would send a generated client - // that branches on `category == 'validation'` to render field errors down the wrong arm. - $invalid = new ValidationException('Validation failed', [new FieldError('ratio', 'must be a number', rejectedValue: INF)]); +it('falls back to a minimal document when an extension member\'s own jsonSerialize() throws', function () { + // THE CASE JSON_THROW_ON_ERROR DOES NOT REACH. json_encode does not merely refuse an object — it CALLS + // the object's code, and whatever that code raises comes out of json_encode with no error state set + // and no JsonException in it, so `catch (JsonException)` let it straight through render(). Extension + // members are `mixed` and chosen at the throw site, so an Eloquent model under preventLazyLoading or + // any accessor that reads the database is a realistic member — and it is likeliest to throw exactly + // when the database is the thing that already failed, i.e. while this renderer describes that failure. + $thrower = new class implements JsonSerializable + { + public function jsonSerialize(): mixed + { + throw new RuntimeException('the accessor could not read the balance either'); + } + }; + + $impossible = (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT')) + ->withExtensions(['balance' => $thrower]); + + $request = Request::create('/api/x'); + $request->headers->set(CorrelationIdFilter::HEADER, 'corr-88'); + + $response = (new ProblemDetailsRenderer)->render($impossible, $request); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + expect($response->getStatusCode())->toBe(409) + ->and($payload['status'])->toBe(409) + ->and($payload['code'])->toBe('LEDGER_CONFLICT') + ->and($payload['category'])->toBe('business') + ->and($payload['detail'])->toBe('The ledger disagrees.') + // Still correlatable, which is the one action the degraded document exists to keep possible. + ->and($payload['traceId'])->toBe('corr-88') + ->and($payload)->not->toHaveKey('balance'); +}); + +it('keeps the field errors, and drops only the rejected value, when a rejected value cannot be encoded', function () { + // FieldError::$rejectedValue is `mixed` — literally whatever the client sent, since the validators fill + // it with Arr::get($data, $field) off the decoded body — so a 422 is a real route into the fallback, + // and json_decode('{"ratio": 1e999}') is float(INF), i.e. any caller can reach it by posting a number. + // Two things must survive that. The category, because a 422 saying `category: internal` sends a + // generated client branching on `category == 'validation'` down the wrong arm. And the errors + // THEMSELVES, because sending it down the RIGHT arm with nothing to render is the same bug wearing a + // different hat — and the second field below, whose rejected value is an ordinary string, was never + // implicated in the failure at all. + $invalid = new ValidationException('Validation failed', [ + new FieldError('ratio', 'must be a number', rejectedValue: INF), + new FieldError('email', 'must be a valid email address', code: 'EMAIL_INVALID', rejectedValue: 'nope', constraint: 'Email'), + ]); $response = (new ProblemDetailsRenderer)->render($invalid, Request::create('/api/x')); @@ -351,8 +395,18 @@ ->and($payload['category'])->toBe('validation') ->and($payload['code'])->toBe('VALIDATION_ERROR') ->and($payload['detail'])->toBe('Validation failed') - // `errors` genuinely holds the value that could not be encoded, so it is the one member that goes. - ->and($payload)->not->toHaveKey('errors'); + // The member stays, and every declared-string part of both entries with it. + ->and($payload['errors'])->toBe([ + ['field' => 'ratio', 'message' => 'must be a number'], + ['field' => 'email', 'message' => 'must be a valid email address', 'code' => 'EMAIL_INVALID', 'constraint' => 'Email'], + ]) + // `rejectedValue` is the ONLY `mixed` member of a FieldError, so it is the only one that goes — + // from both entries, because the renderer cannot know which of them json_encode refused. + ->and((string) $response->getContent())->not->toContain('rejectedValue') + ->and((string) $response->getContent())->not->toContain('nope') + // `errors` stays LAST, where STANDARD_MEMBERS has it, so the degraded document still reads like + // the full one to a person diffing the two. + ->and(array_key_last($payload))->toBe('errors'); }); it('answers with a document even when the status itself is the only thing left', function () { diff --git a/packages/web/tests/Fixtures/Advice/ConflictController.php b/packages/web/tests/Fixtures/Advice/ConflictController.php index 90fd3fd3..d98835a0 100644 --- a/packages/web/tests/Fixtures/Advice/ConflictController.php +++ b/packages/web/tests/Fixtures/Advice/ConflictController.php @@ -9,6 +9,8 @@ use Firefly\Web\Attributes\GetMapping; use Firefly\Web\Attributes\RequestMapping; use Firefly\Web\Attributes\RestController; +use JsonSerializable; +use RuntimeException; #[RestController] #[RequestMapping('/errors')] @@ -57,6 +59,32 @@ public function unencodable(): array throw (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['ratio' => INF]); } + /** + * THE ARM JSON_THROW_ON_ERROR NEVER REACHES. The two routes above are values json_encode REFUSES and + * reports; this one is a value whose own code json_encode CALLS — and a jsonSerialize() or an Eloquent + * accessor that throws propagates straight out of json_encode with no error state set and no + * JsonException in it, which is why catching JsonException left the renderer able to throw after all. + * The member is `mixed` and chosen at the throw site, and the realistic version of this object is a + * model whose accessor reads the database — the thing that already failed, which is why the renderer + * was running at all. Through the pipeline, because a blank 500 is again what the layer above does + * with the exception the renderer threw, and a direct call can only observe the throw. + * + * @return array + */ + #[GetMapping('/throwing-extension')] + public function throwingExtension(): array + { + $balance = new class implements JsonSerializable + { + public function jsonSerialize(): mixed + { + throw new RuntimeException('the accessor could not read the balance either'); + } + }; + + throw (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['balance' => $balance]); + } + /** * Controller-LOCAL handler — beats any global #[ControllerAdvice] for CustomBusinessException. Its return * value renders at the exception's httpStatus() (409), not the route's 200 (exercises the matched-handler From 10ba9da7c9fd3946a38bcd94aae178fd116dd58b Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 19:53:14 -0700 Subject: [PATCH 47/78] fix(admin): break a tied listing sort on the identifier, and build every data-browser link from parsed values --- .../admin/resources/views/data-list.blade.php | 161 +++++++----------- .../admin/resources/views/layout.blade.php | 23 +-- packages/admin/src/Data/DataFilter.php | 41 +++-- packages/admin/src/Data/DataQueryEngine.php | 46 ++++- packages/admin/src/Table/ListingQuery.php | 32 ++++ packages/admin/src/Web/AdminAction.php | 43 +++-- .../admin/tests/Data/DataStableSortTest.php | 153 +++++++++++++++++ packages/admin/tests/DataBrowserPageTest.php | 60 +++++++ .../admin/tests/Table/ListingQueryTest.php | 54 ++++++ 9 files changed, 462 insertions(+), 151 deletions(-) create mode 100644 packages/admin/tests/Data/DataStableSortTest.php diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index 198cbc12..c66a3e3e 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -9,15 +9,12 @@ $schema = $listing->schema; $columns = $listing->columns(); $identifier = $schema?->identifierColumn(); - $base = $settings->url('data').'?resource='.urlencode($resource?->slug ?? ''); - // Everything a link must carry to survive being clicked. A sort that dropped the filter would widen - // the listing back to every row, which reads as rows appearing from nowhere; a filter that dropped - // the page size would silently resize the table under the reader. - $keepFilter = $listing->filterQuery() === '' ? '' : '&'.$listing->filterQuery(); - $keepSearch = $listing->search !== null ? '&q='.urlencode($listing->search) : ''; - $keepSize = '&size='.$listing->perPage; - $keepSort = $listing->sort !== null ? '&sort='.urlencode($listing->sort).'&dir='.$listing->direction : ''; + // $base names a RECORD, not a listing: `&id=` and `&new=1` leave the table rather than moving + // within it, so they carry the resource and nothing else. Every link that stays in the listing goes + // through $query->link(), which is why the four "do not forget to carry this" strings that used to + // live here are gone. + $base = $settings->url('data').'?resource='.urlencode($resource?->slug ?? ''); @endphp
    @@ -37,6 +34,13 @@ @endforeach @endif

    + {{-- Beside the title rather than inside the panel header: that header is now the shared + _panel-head, whose contract is a title, a count and a search form — and a "New record" link is + none of those. It is the one control on this page that creates something, so it reads better at + the top anyway. --}} + @if ($writable && $resource?->isEloquentBacked()) + New record + @endif
    @if ($listing->filters !== []) @@ -45,7 +49,7 @@ @foreach ($listing->filters as $filter) {{ $filter->describe() }}@if (! $loop->last) and @endif @endforeach - · clear + · clear

    @endif @@ -71,12 +75,19 @@ {{ count($listing->filters) ?: 'none' }}{{ count($listing->filters) ? ' active' : '' }} -
    - - @if ($listing->sort)@endif - - - @if ($listing->search !== null)@endif + + {{-- The resource, the ordering and the size, from the listing that produced this page. The + `f…` parameters are skipped because this form's own rows ARE the filter: re-submitting + the applied one as a hidden field would double every condition. `q` is not among what + hiddenFields() emits — that method is written for the search form, whose owns + the name — so this form, which has no such input, re-adds it by hand. Without it, + applying a filter from inside a searched listing silently widens it to the whole + table. --}} + @foreach ($query->hiddenFields() as $field) + @continue (str_starts_with($field['name'], 'f')) + + @endforeach + @if ($query->search !== null)@endif
    @php $rows = $listing->filters; $rows[] = null; @endphp @@ -108,7 +119,7 @@ @if ($listing->filters !== []) - Clear + Clear @endif Conditions are combined with and.
    @@ -116,30 +127,15 @@
    -
    -

    Records

    - - @if ($schema->searchable() !== []) - - - @if ($listing->sort)@endif - - - {{-- Searching inside a filtered listing NARROWS it; without these the search box - would silently drop the filter and search the whole table. --}} - @foreach ($listing->filters as $filter) - - - - @endforeach - - - @endif - @if ($writable && $resource?->isEloquentBacked()) - New record - @endif - {{ number_format($listing->total) }} total -
    + {{-- The same header every other listing on the dashboard draws. Its search form re-submits the + resource, the ordering, the size and the filters through hiddenFields(), so searching + inside a filtered listing NARROWS it instead of silently widening to the whole table; and + the count is the formatted grand total followed by the word `total`, the wording this page + hand-rolled and tests/Browser/AdminDataBrowserTest.php reads as `3 total` / `1 total`. --}} + @include('firefly-admin::_panel-head', [ + 'title' => 'Records', 'count' => $listing->total, 'query' => $query, + 'placeholder' => 'Search records…', + ]) @if ($listing->isEmpty()) @include('firefly-admin::_empty', [ @@ -150,20 +146,31 @@ ]) @else
    - + {{-- `ftable` FIRST, `datatable` BESIDE IT. The shared class is what brings the fixed + layout and the scrollport every other listing has; the second one keeps the + DATABASE's own type classes — int, float, bool, datetime, string, json — which are + a fact about the resource DataSchema derived rather than a presentation choice this + view makes, and which no other page has. --}} +
    + {{-- The the rest of the dashboard gets from TableView, computed here + from the SCHEMA instead: this table's columns come from a resource, not from a + hand-written view model. A column whose values have a known maximum width takes + it and no more (the padding is added because box-sizing is border-box), and the + text and json columns share what is left — which is where a reader needs it. --}} + + @foreach ($columns as $column) + + @endforeach + @if ($identifier !== null)@endif + @foreach ($columns as $column) - @php - // searchable()/sortable() return column NAMES, not DataColumn objects. - $sortable = in_array($column->name, $schema->sortable(), true); - $isSorted = $listing->sort === $column->name; - $next = $isSorted && $listing->direction === 'asc' ? 'desc' : 'asc'; - @endphp + {{-- sortable() returns column NAMES, not DataColumn objects. --}}
    - @if ($sortable) - - {{ $column->label() }}{{ $isSorted ? ($listing->direction === 'asc' ? '↑' : '↓') : '' }} + @if (in_array($column->name, $schema->sortable(), true)) + + {{ $column->label() }}{{ $query->indicator($column->name) }} @else {{ $column->label() }} @@ -193,59 +200,7 @@
    - @php - $keep = $keepSort.$keepSearch.$keepFilter.$keepSize; - $last = max(1, $listing->totalPages()); - $from = $listing->total === 0 ? 0 : ($listing->page - 1) * $listing->perPage + 1; - $to = min($listing->total, $listing->page * $listing->perPage); - // A window around the current page. Rendering every page of a 400-page table is a - // pagination control nobody can use, and the ends are kept because "first" and "last" are - // the two jumps people actually make. - $window = range(max(1, $listing->page - 2), min($last, $listing->page + 2)); - @endphp -
    - - {{ number_format($from) }}–{{ number_format($to) }} of {{ number_format($listing->total) }} - @if ($last > 1) · page {{ $listing->page }} of {{ number_format($last) }} @endif - -
    - - @if ($listing->sort)@endif - - @if ($listing->search !== null)@endif - @foreach ($listing->filters as $filter) - - - - @endforeach - -
    - - @if ($last > 1) - hasPrevious()) href="{{ $base }}&page={{ $listing->page - 1 }}{{ $keep }}" @endif>Previous - @if ($window[0] > 1) - 1 - @if ($window[0] > 2)…@endif - @endif - @foreach ($window as $n) - {{ $n }} - @endforeach - @if (end($window) < $last) - @if (end($window) < $last - 1)…@endif - {{ $last }} - @endif - hasNext()) href="{{ $base }}&page={{ $listing->page + 1 }}{{ $keep }}" @endif>Next - @endif -
    + @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) @endif
    @endif diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 59a12b73..84a0d500 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -426,24 +426,13 @@ .pair{display:inline-flex;align-items:baseline;gap:5px;margin:0 8px 4px 0;font-family:var(--mono);font-size:11.5px;white-space:nowrap} /* ── The data grid ───────────────────────────────────────────────────────────────────────────── - A table is read DOWN a column, not across a row, so the type decides the alignment: figures are - right-aligned with tabular numerals so digits line up and a long number is visibly long, and text - stays left. Everything is clipped to one line with the full value on the title, because a table - whose row height depends on its longest json blob is not a table. + The data browser's own type classes, on top of the shared `.ftable` vocabulary. They are the + DATABASE's types rather than the dashboard's kinds — int, float, bool, datetime, string, json — + and they survive because a column's type is a fact about the resource that DataSchema derived, + not a presentation choice a view makes. The width behaviour they used to carry is gone: the +
    "` rather than the bare name: sqlite's own column introspection mentions the + // table inside `pragma_table_info('
    ')` and carries an `order by cid asc` of its own. + if (! str_contains($sql, 'from "'.$table.'"')) { + continue; + } + + if (preg_match('/ order by (.+?)(?: limit | offset |$)/i', $sql, $matches) === 1) { + $clauses[] = $matches[1]; + } + } + + DB::disableQueryLog(); + + return $clauses; +} + +it('breaks a tied sort on the identifier, on the SQL path', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(); + + $clauses = orderClausesFor('admin_records', static fn (): mixed => $browser->list('admin-record', 1, 2, 'active', 'desc')); + + expect($clauses)->not->toBeEmpty() + ->and($clauses[0])->toContain('"active" desc') + // ASCENDING, under a descending primary. It is an identity, not a second ordering: mirroring it + // would make the order WITHIN a tie depend on the direction it is breaking the tie inside. + ->and($clauses[0])->toContain('"id" asc'); +}); + +it('does not repeat the identifier when it is already the sort column', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(); + + $clauses = orderClausesFor('admin_records', static fn (): mixed => $browser->list('admin-record', 1, 2, 'id', 'desc')); + + // `ORDER BY id DESC, id ASC` is at best noise and at worst an index the planner declines to use. + expect($clauses)->not->toBeEmpty()->and($clauses[0])->toBe('"id" desc'); +}); + +it('sees every row exactly once when paging a sort whose values all tie', function () { + /** @var DataBrowserTestCase $this */ + $rows = []; + foreach (range(1, 40) as $n) { + $rows[] = [ + 'id' => $n, + 'email' => 'row-'.str_pad((string) $n, 2, '0', STR_PAD_LEFT).'@example.test', + 'api_token' => null, + 'recovery_phrase' => null, + 'amount' => 10, + 'active' => 1, + 'meta' => null, + 'created_at' => null, + ]; + } + DB::table('admin_records')->insert($rows); + + $browser = $this->browser(); + + $seen = []; + foreach (range(1, 4) as $page) { + foreach ($browser->list('admin-record', $page, 10, 'active', 'desc')->rows as $row) { + $seen[] = $this->stringCell($row, 'email'); + } + } + + expect($seen)->toHaveCount(40)->and(array_unique($seen))->toHaveCount(40); +}); + +it('keeps the tiebreak ascending under a descending primary sort, so the tie order never mirrors', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(); + + // Every seeded row shares nothing but `amount`'s type, so the tie is made here: one value in `amount` + // across all five, leaving the identifier as the only thing that can decide the order. + DB::table('admin_records')->update(['amount' => 7]); + + $descending = $browser->list('admin-record', 1, 10, 'amount', 'desc'); + $ascending = $browser->list('admin-record', 1, 10, 'amount', 'asc'); + + expect(array_column($descending->rows, 'id'))->toBe([1, 2, 3, 4, 5]) + ->and(array_column($ascending->rows, 'id'))->toBe(array_column($descending->rows, 'id')); +}); + +/** + * The same tiebreak on the OTHER engine — the `findAll()`-then-slice-in-PHP fallback a repository that + * cannot page takes. + * + * `usort` is stable in PHP 8, and this fixture's `findAll()` happens to return its rows in identifier + * order, so the array being stabilised already carries the answer the tiebreak gives: this passes before + * the fix as well as after it. It is kept because stability of the SORT is not stability of the PAGE + * BOUNDARY — the array is rebuilt from the repository on every request, and a repository whose `findAll()` + * returns rows in whatever order its storage found convenient makes the tied order move between the + * request that builds page 1 and the request that builds page 2. This is what says so. + */ +it('breaks a tied sort on the identifier on the in-PHP fallback too', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + DB::table('admin_notes')->insert([ + ['id' => 4, 'title' => 'Delta', 'body' => 'fourth note', 'pinned' => 0], + ['id' => 5, 'title' => 'Epsilon', 'body' => 'fifth note', 'pinned' => 0], + ['id' => 6, 'title' => 'Zeta', 'body' => 'sixth note', 'pinned' => 0], + ]); + DB::table('admin_notes')->update(['pinned' => 0]); + + $browser = $this->browser(); + + $seen = []; + foreach (range(1, 3) as $page) { + $seen = [...$seen, ...array_column($browser->list('plain-note', $page, 2, 'pinned', 'desc')->rows, 'id')]; + } + + expect($seen)->toBe([1, 2, 3, 4, 5, 6]); +}); diff --git a/packages/admin/tests/DataBrowserPageTest.php b/packages/admin/tests/DataBrowserPageTest.php index b2a6b40b..e6840e9e 100644 --- a/packages/admin/tests/DataBrowserPageTest.php +++ b/packages/admin/tests/DataBrowserPageTest.php @@ -96,3 +96,63 @@ expect(DB::table('admin_records')->where(['id' => 2, 'email' => 'katherine@example.test', 'amount' => 7, 'active' => 1])->exists())->toBeTrue(); }); + +/** + * The listing itself, rebuilt on the shared table system. + * + * WHAT THIS PINS IS THE LINKS, not the decoration. Every href on this page used to be four concatenated + * strings — `$keepFilter`, `$keepSearch`, `$keepSize`, `$keepSort` — with a comment beside them asking the + * next author not to forget one, and a sort link that dropped the filter widens the listing back to every + * row: rows appear from nowhere and nothing fails. Now one ListingQuery carries the resource and the + * filter, `sortLink()` is the only way a header href is written, and this is what says the carry survived. + */ +it('renders the listing through the shared table system, carrying the resource and the filter into every link', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminRecords(); + DB::table('admin_records')->insert([ + ['id' => 2, 'email' => 'grace@example.test', 'amount' => 150, 'active' => 1, 'created_at' => null], + ['id' => 3, 'email' => 'linus@example.test', 'amount' => 250, 'active' => 0, 'created_at' => null], + ]); + + $html = (string) $this->get('/firefly/data?resource=admin-record&fk=active&fv=1')->assertStatus(200)->getContent(); + + expect($html) + // ONE table class, with the database's own type classes beside it rather than instead of it. + ->toContain('
    ') + ->toContain('') + // The shared panel header, and the wording tests/Browser/AdminDataBrowserTest.php reads. + ->toContain('2 total') + // The shared pager's range readout. + ->toContain('1–2 of 2') + // A sort header, carrying the resource AND the filter it was clicked inside. + ->toContain('resource=admin-record&fk=active&fv=1&sort=amount') + // The search form re-submits the filter, so searching inside it narrows rather than widens. + ->toContain('') + ->toContain('') + // The one control that leaves the listing keeps carrying the resource and nothing else. + ->toContain('New record'); +}); + +/** + * `?page=999` on a two-page listing is a hand-edited URL or a stale bookmark, not an error. The in-memory + * listings clamp it because they know the total before they slice; SQL does not, so it comes back as an + * empty slice with a real total — an empty table under a pager that says there are thirty rows. The last + * page is fetched instead, which costs one extra query in a case no click can reach. + */ +it('renders the last page rather than an empty table when the page number is past the end', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminRecords(); + + $rows = []; + foreach (range(2, 30) as $n) { + $rows[] = ['id' => $n, 'email' => 'row-'.$n.'@example.test', 'amount' => $n, 'active' => 1, 'created_at' => null]; + } + DB::table('admin_records')->insert($rows); + + $html = (string) $this->get('/firefly/data?resource=admin-record&size=25&page=999')->assertStatus(200)->getContent(); + + expect($html)->toContain('30 total') + ->toContain('26–30 of 30') + ->toContain('row-30@example.test') + ->not->toContain('No records yet'); +}); diff --git a/packages/admin/tests/Table/ListingQueryTest.php b/packages/admin/tests/Table/ListingQueryTest.php index cf8e9842..d2338ec5 100644 --- a/packages/admin/tests/Table/ListingQueryTest.php +++ b/packages/admin/tests/Table/ListingQueryTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Admin\Data\DataFilter; use Firefly\Admin\Table\ListingQuery; use Firefly\Admin\Table\TableSettings; use Illuminate\Http\Request; @@ -149,3 +150,56 @@ function listingQuery(Request $request, string ...$sortable): ListingQuery ['name' => 'size', 'value' => '25'], ]); }); + +/** + * The shape `ListingQuery::$carried` is handed for the data browser's filters. It belongs beside the other + * link-building assertions rather than in the data suite: what it pins is not how a filter QUERIES, it is + * that a filter reaches a link builder as parsed parameters instead of as a hand-concatenated string. + */ +it('turns a filter set into query parameters rather than a hand-built string', function () { + $filters = [ + new DataFilter('customer', DataFilter::CONTAINS, 'Hopper'), + new DataFilter('total', DataFilter::GT, '5'), + ]; + + expect(DataFilter::toParameters($filters))->toBe([ + 'fc' => ['customer', 'total'], + 'fo' => ['contains', 'gt'], + 'fv' => ['Hopper', '5'], + ]); + + // The short spelling a relation link produces stays exactly what it was — it is short enough to read + // in a status bar, and every relation link in the tree carries it. + expect(DataFilter::toParameters([new DataFilter('order_id', DataFilter::EQ, '7')])) + ->toBe(['fk' => 'order_id', 'fv' => '7']); + + expect(DataFilter::toParameters([]))->toBe([]); +}); + +// The string form is the same parameters, built by http_build_query rather than by concatenating +// urlencode() calls — one representation, two spellings of it. +it('keeps a query-string form that agrees with the parameters, escaping included', function () { + $filters = [new DataFilter('customer', DataFilter::EQ, 'Grace Hopper')]; + + expect(DataFilter::toQuery($filters))->toBe('fk=customer&fv=Grace+Hopper') + ->and(DataFilter::toQuery([]))->toBe(''); +}); + +/** + * A listing may be SERVED at a size the query did not ask for. `DataBrowser::list()` applies + * `firefly.admin.data.max-page-size` on top of the table's own offered set, because a size that is merely + * large on an actuator payload materialises a whole table into PHP memory on a repository that cannot page. + * When that cap is the tighter of the two, every number a pager draws would still be computed from the size + * that was refused — the last page halved, `Next` dead with half the table unreached — so the query is + * re-stated at the size that was served, and its links carry that one. + */ +it('re-states itself at the size it was actually served, and leaves itself alone when nothing changed', function () { + $query = listingQuery(listingRequest(['size' => '200', 'page' => '3']), 'path'); + + $served = $query->sized(25); + + expect($served->size)->toBe(25) + ->and($served->page)->toBe(3) + ->and($served->link())->toBe('/firefly/mappings?size=25&page=3') + ->and($query->sized(200))->toBe($query); +}); From b0efb59512b41741c768fea9fc6a744ce323a337 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 20:03:38 -0700 Subject: [PATCH 48/78] fix(web): the degraded problem document reports the throwable it swallowed and names the members it could not carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encoder's fallback caught an arbitrary application exception in `catch (Throwable) { $json = false; }` and recorded it in no place at all: no log line, no marker, no header. The document it published carried the same status, code and category as a healthy one, so an application losing its extension members from every problem document it rendered — an Eloquent accessor under preventLazyLoading is the ordinary shape of this — had no way for an operator to learn that it was happening, or why. An error-handling subsystem that fails unobservably is the failure this wave exists to remove; the blank 500 went away and took the evidence with it. Both halves are closed. The catch now BINDS the throwable and reports it through an optional Psr\Log\LoggerInterface — a second constructor argument WebServiceProvider fills from the application's own logger, defensively, for the reason ErrorPageRenderer resolves its view factory that way — with the document's `traceId` in the context so the log line and the body a caller is holding join up, and inside a nested try/catch so a log channel that is down too cannot throw out of the error handler and put the blank 500 back one layer up. And minimal() stops deleting the RFC 9457 open namespace. encodable() walks each extension member by type — scalars verbatim, arrays to a bounded depth so a self-referential one cannot trade a failed encode for a failed stack, enums by their own case, a non-finite float named rather than typed, and everything else as get_debug_type() — so `allowed` still lists a 405's verbs and a member nobody can read says `App\Models\Balance` instead of vanishing. That is ConfigPropsEndpoint::value()'s idiom and its stated reason: naming the type never calls the application's code, which is what threw on the way in. FieldError::$rejectedValue is still dropped outright, because its whole contract is "this is the value you sent" and "float" is not something anyone sent. --- book/src-es/04-first-http-api.md | 6 +- book/src/04-first-http-api.md | 6 +- .../src/Exception/ProblemDetailsRenderer.php | 230 ++++++++++++++++-- packages/web/src/WebServiceProvider.php | 24 +- .../web/tests/CapstoneWebIntegrationTest.php | 56 ++++- .../Exception/ProblemDetailsRendererTest.php | 168 ++++++++++++- 6 files changed, 457 insertions(+), 33 deletions(-) diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index 2b35f09f..229bb3fa 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -448,7 +448,7 @@ final class ProblemDetailsRenderer // … return new Response( - self::encode($payload), + $this->encode($payload, $reference), $exception->httpStatus(), $headers, ); @@ -462,7 +462,9 @@ final class ProblemDetailsRenderer El corte que hay encima del `return` oculta el resto del trabajo con cabeceras: las cabeceras que una `HttpExceptionInterface` ya trae se copian sobre la respuesta — el `Allow` de un `405`, entre ellas — y un `503` gana además `Retry-After: 5`. -`self::encode()` es el cuerpo, y es total a propósito. Un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse. Cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo; y también cae ahí cualquier cosa que lance el código *propio* de un objeto codificado, porque codificar un objeto llama a ese código — un `jsonSerialize()`, un accesor de Eloquent — y lo que lance no llega a ser una `JsonException` siquiera, razón por la cual el respaldo captura `Throwable` y no sólo la de JSON. Ese documento conserva todos los miembros que la forma de arriba define — el estado, el título, el código, la categoría, la severidad, la frase y los identificadores de referencia — porque cada uno es una cadena que escribió esta clase y ninguno puede ser lo que `json_encode` rechazó. Lo que descarta es lo que sí puede serlo, y sólo eso: los miembros de extensión, que son `mixed` sin excepción, y el `rejectedValue` de cada error de campo. Los errores de campo en sí se quedan, con su nombre y su frase intactos — un `422` que siguiera diciendo `category: validation` sin llevar miembro `errors` mandaría a un cliente generado a la rama de errores de campo sin nada que pintar. Un renderizador que lanzara aquí fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. +`$this->encode()` es el cuerpo, y es total a propósito. Un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse. Cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo; y también cae ahí cualquier cosa que lance el código *propio* de un objeto codificado, porque codificar un objeto llama a ese código — un `jsonSerialize()`, un accesor de Eloquent — y lo que lance no llega a ser una `JsonException` siquiera, razón por la cual el respaldo captura `Throwable` y no sólo la de JSON. Ese documento conserva todos los miembros que la forma de arriba define — el estado, el título, el código, la categoría, la severidad, la frase y los identificadores de referencia — porque cada uno es una cadena que escribió esta clase y ninguno puede ser lo que `json_encode` rechazó. Conserva también los errores de campo, con su nombre y su frase intactos — un `422` que siguiera diciendo `category: validation` sin llevar miembro `errors` mandaría a un cliente generado a la rama de errores de campo sin nada que pintar — y conserva el *nombre* de cada miembro de extensión: un valor que no puede llevar se escribe como el nombre de su tipo (`"balance": "App\Models\Balance"`) en vez de borrarse, que es como el endpoint de configuración de `firefly/actuator` lleva respondiendo siempre a esta misma pregunta. Sólo se pierden dos cosas: el valor ilegible, sustituido por su tipo, y el `rejectedValue` de cada error de campo, que se descarta del todo porque el contrato entero de ese miembro es *esto es lo que enviaste* y la palabra `float` no la envió nadie. + +Y la degradación queda registrada. El throwable que capturó el respaldo es una excepción cualquiera de la aplicación, así que se registra — por el `Psr\Log\LoggerInterface` opcional que el provider le pasa a este renderizador, con el `traceId` del propio documento en el contexto para que la línea de log y el cuerpo que tiene quien llama se puedan juntar, y dentro de su propio `try`/`catch` para que un canal de log que también esté caído no pueda lanzar fuera del manejador de errores. Un subsistema de manejo de errores que falla de forma invisible es justo lo que toda esta superficie existe para eliminar, y un renderizador que se tragara una excepción en silencio mientras publica un documento indistinguible de uno sano sería exactamente eso. Un renderizador que lanzara aquí, por su parte, fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno. Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se convierte un throwable cualquiera. Esa regla vive una clase más allá, en `Firefly\Web\Error\ProblemMapper`, porque la página de error HTML del Capítulo 10 necesita la respuesta idéntica y dos copias de ella acabarían dándole a un navegador y a un cliente de API códigos distintos para el mismo fallo: diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md index c585db18..4359e6ae 100644 --- a/book/src/04-first-http-api.md +++ b/book/src/04-first-http-api.md @@ -448,7 +448,7 @@ final class ProblemDetailsRenderer // … return new Response( - self::encode($payload), + $this->encode($payload, $reference), $exception->httpStatus(), $headers, ); @@ -462,7 +462,9 @@ final class ProblemDetailsRenderer The cut above the `return` hides the rest of the header work: the headers an `HttpExceptionInterface` already carries are copied onto the response — a `405`'s `Allow` among them — and a `503` additionally gains `Retry-After: 5`. -`self::encode()` is the body, and it is total on purpose. A byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised. Anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document; so does anything an encoded object's *own* code throws, because encoding an object calls that code — a `jsonSerialize()`, an Eloquent accessor — and what it raises never becomes a `JsonException` at all, which is why the fallback catches `Throwable` and not just the JSON one. That fallback keeps every member the shape above defines — the status, the title, the code, the category, the severity, the sentence and the reference ids — because each of them is a string this class wrote and none of them can be what `json_encode` refused. What it drops is what can be, and only that: the extension members, which are `mixed` to a one, and each field error's `rejectedValue`. The field errors themselves stay, names and sentences intact — a `422` that still says `category: validation` while carrying no `errors` member would send a generated client down the field-error arm with nothing to render. A renderer that threw here would fail while handling the failure, and the caller would receive no document at all. +`$this->encode()` is the body, and it is total on purpose. A byte that is not valid UTF-8 — a driver message quoting a latin-1 column, a request header echoed into an extension member — is substituted rather than raised. Anything `json_encode` still refuses, such as an `INF` an application put in an extension at the throw site, falls back to a minimal document; so does anything an encoded object's *own* code throws, because encoding an object calls that code — a `jsonSerialize()`, an Eloquent accessor — and what it raises never becomes a `JsonException` at all, which is why the fallback catches `Throwable` and not just the JSON one. That fallback keeps every member the shape above defines — the status, the title, the code, the category, the severity, the sentence and the reference ids — because each of them is a string this class wrote and none of them can be what `json_encode` refused. It keeps the field errors too, names and sentences intact — a `422` that still says `category: validation` while carrying no `errors` member would send a generated client down the field-error arm with nothing to render — and it keeps every extension member's *name*, rendering a value it cannot carry as the name of its type (`"balance": "App\Models\Balance"`) rather than deleting it, which is how `firefly/actuator`'s config endpoint has always answered the same question. Only two things actually go: an unreadable value, replaced by its type, and each field error's `rejectedValue`, which is dropped outright because that member's whole contract is *this is the value you sent* and the word `float` is not something anybody sent. + +And the degradation is on the record. The throwable the fallback caught is an arbitrary application exception, so it is logged — through the optional `Psr\Log\LoggerInterface` the provider hands this renderer, with the document's own `traceId` in the context so the log line and the body a caller is holding join up, and inside a `try`/`catch` of its own so a log channel that is down too cannot throw out of the error handler. An error-handling subsystem that fails invisibly is the thing this whole surface exists to remove, and a renderer that quietly swallowed an exception while publishing a document nobody could tell from a healthy one would have been exactly that. A renderer that threw here, meanwhile, would fail while handling the failure, and the caller would receive no document at all. What `render()` deliberately does **not** decide is which `FireflyException` an arbitrary throwable becomes. That rule lives one class along, in `Firefly\Web\Error\ProblemMapper`, because the HTML error page of Chapter 10 needs the identical answer and two copies of it would eventually hand a browser and an API client different codes for the same failure: diff --git a/packages/web/src/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index 44517849..60ba80c1 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -4,6 +4,7 @@ namespace Firefly\Web\Exception; +use BackedEnum; use DateTimeImmutable; use DateTimeInterface; use Firefly\Kernel\Error\ErrorCategory; @@ -15,8 +16,10 @@ use Firefly\Web\Trace\TraceContext; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; +use UnitEnum; /** * Renders any throwable as an application/problem+json response via ErrorResponse::fromException (a thin map @@ -49,15 +52,37 @@ * raised, and anything json_encode still refuses — or anything an encoded object's OWN jsonSerialize() * or accessor throws, which JSON_THROW_ON_ERROR never sees and which catching JsonException therefore * never caught — falls back to a minimal document. That document keeps every standard member, the - * reference among them, and every field error's name and sentence, and drops only the values that can - * be the cause. This method used to throw JsonException out of the error handler on a latin-1 byte in - * a driver message, which turned a described failure into a blank 500 with no document at all. + * reference among them, every field error's name and sentence, and every extension member's NAME, and + * drops only the values that can be the cause. This method used to throw JsonException out of the + * error handler on a latin-1 byte in a driver message, which turned a described failure into a blank + * 500 with no document at all. + * - AND THE DEGRADATION SAYS SO, in the two places a person looks. An error-handling subsystem that + * fails INVISIBLY is the failure this whole surface exists to remove, and for one release the fallback + * above was exactly that: it caught a Throwable an application's own accessor had raised, dropped the + * open namespace on the floor, and published a document a healthy one could not be told apart from — + * same status, same code, same category, no marker, and not one log line anywhere in the process. A + * misconfigured application lost its extension members from every problem document it published, over + * and over, with no way for an operator to learn that it was happening or why. So: the caught + * throwable is REPORTED through the optional logger below, with the document's own reference in the + * context so the log line and the body a caller is holding join up; and the document itself NAMES what + * it could not carry, because minimal() renders an unencodable member as its type instead of deleting + * it — Firefly\Actuator\Introspection\ConfigPropsEndpoint::value() has answered the same question that + * way since it shipped, and saying `"balance": "App\Models\Balance"` is more use to everyone than a + * member that silently was not there. */ final class ProblemDetailsRenderer { /** Seconds a caller is told to wait before retrying a 503. Short, for the reason in the class comment. */ public const int RETRY_AFTER_SECONDS = 5; + /** + * How deep encodable() walks an extension member's arrays before it stops describing and starts naming. + * ConfigPropsEndpoint's number, for ConfigPropsEndpoint's reason: a bound is what makes the walk total + * against a structure that refers to itself, and eight levels is deeper than any context an application + * hangs off a throw site. + */ + private const int MAX_DEPTH = 8; + /** * THE DISCLOSURE GATE IS THE PROBLEM DOCUMENT'S OWN. For one release this path shared the HTML page's * `trace` (which follows `app.debug`), and that was the wrong gate for a machine surface: every local @@ -66,8 +91,19 @@ final class ProblemDetailsRenderer * `ErrorPageSettings::$disclose` (`firefly.web.problem.disclose`) is read instead, defaults to false and * inherits from nothing. The settings object is optional so a JSON-only deployment that never bound one * still renders — and when it is absent the default is the SAFE one. + * + * THE LOGGER IS THE DEGRADED DOCUMENT'S WITNESS, and it has no configuration key of its own on purpose. + * It is not a feature an application turns on: it is the record of a failure INSIDE the error handler, + * and a key whose off position means "lose the only evidence" is a key nobody should be offered. It is + * optional for the same reason the settings object is — a JSON-only deployment, or one of the dozens of + * `new ProblemDetailsRenderer` in this repository's tests, binds no logger and must still render — and + * WebServiceProvider hands over the application's when there is one. Nothing else in this class reads + * it; see report(), which is also where a logger that throws is dealt with. */ - public function __construct(private readonly ?ErrorPageSettings $settings = null) {} + public function __construct( + private readonly ?ErrorPageSettings $settings = null, + private readonly ?LoggerInterface $logger = null, + ) {} public function render(Throwable $e, Request $request): Response { @@ -114,7 +150,7 @@ public function render(Throwable $e, Request $request): Response } return new Response( - self::encode($payload), + $this->encode($payload, $reference), $exception->httpStatus(), $headers, ); @@ -148,17 +184,61 @@ public function render(Throwable $e, Request $request): Response * that already failed — which is to say, while this renderer is describing that failure. Neither arm is * a reason to answer a caller with nothing. * + * AND NEITHER ARM IS A REASON TO SAY NOTHING EITHER, which is what the first spelling of this method + * did. `catch (Throwable) { $json = false; }` swallowed an arbitrary application exception whole: the + * RuntimeException an Eloquent accessor raised under preventLazyLoading went into that pair of braces + * and out of the process, and the degraded document that came back was byte-indistinguishable from a + * healthy one. Both halves of that are fixed below — the catch BINDS the throwable and report() hands + * it to the logger, and minimal() names every member it could not carry instead of deleting it — so + * the one arm of this subsystem that can still fail is the one arm nobody could previously observe. + * * @param array $payload + * @param ?string $reference the document's own `traceId`, so the log line and the body a caller is + * holding can be joined up — the whole point of publishing one */ - private static function encode(array $payload): string + private function encode(array $payload, ?string $reference): string { try { - $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); - } catch (Throwable) { - $json = false; + // JSON_THROW_ON_ERROR is what makes the catch the WHOLE fallback: with it set json_encode never + // answers false, it raises, so there is no second failure mode for this method to miss. + return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + } catch (Throwable $cause) { + $this->report($cause, $reference); + + return self::minimal($payload); + } + } + + /** + * Says that a problem document was degraded, and why, to whoever is listening. + * + * IT IS WRAPPED IN ITS OWN try/catch, and that is not belt-and-braces theatre. This runs inside the + * error handler, with an application already failing, and the logger is a live collaborator: a handler + * writing to a full disk, a channel whose remote endpoint is the thing that went down, a Monolog + * processor reading the same broken context. A throw from HERE would escape render() and produce + * exactly the blank 500 the encoder was made total to prevent — the bug back again, one layer up and + * wearing the fix's own clothes. So a logger that fails costs the log line and nothing else. + * + * The message says what happened to the DOCUMENT, because that is what an operator is holding when + * they come looking; `exception` carries the cause under the key PSR-3 reserves for it, which is the + * key Laravel's formatter already knows to expand into a class, a message and a trace. + */ + private function report(Throwable $cause, ?string $reference): void + { + if (! $this->logger instanceof LoggerInterface) { + return; } - return is_string($json) ? $json : self::minimal($payload); + $context = ['exception' => $cause, 'reference' => $reference ?? '']; + + try { + $this->logger->error( + 'The problem document could not be encoded and was degraded: a member it was given cannot be JSON.', + $context, + ); + } catch (Throwable) { + // See the docblock: the renderer still owes the caller a document, and it is about to return one. + } } /** @@ -166,16 +246,23 @@ private static function encode(array $payload): string * a value whose type is checked here rather than trusted, with every string passing through the same * substitution. * - * WHAT IS DROPPED IS WHAT COULD BE THE CAUSE, AND NOTHING ELSE — down to the MEMBER, not the array that - * happens to hold it. Only a NON-string reaches this branch — JSON_INVALID_UTF8_SUBSTITUTE has already - * answered every bad byte — so what brought us here is an extension member an application chose at the - * throw site, a FieldError::$rejectedValue (`mixed`, so literally whatever the client sent), a structure - * too deep or too circular to walk, or an object whose own accessor threw mid-encode. The open namespace - * therefore goes WHOLE, because every one of its members is `mixed` and any of them could be the one. - * `errors` does NOT go whole: exactly one of its members is `mixed`, and fieldErrors() takes that one - * out and keeps the rest. The standard members STAY, because ErrorResponse declares every one of them - * `?string` (`int`, for the status) and array_diff_key() keeps a same-named extension out of the - * document, so not one of them can be why we are here and dropping them buys nothing: + * WHAT IS DROPPED IS WHAT COULD BE THE CAUSE, AND NOTHING ELSE — down to the VALUE, not the member that + * holds it and not the array that holds the member. Only a NON-string reaches this branch — + * JSON_INVALID_UTF8_SUBSTITUTE has already answered every bad byte — so what brought us here is an + * extension member an application chose at the throw site, a FieldError::$rejectedValue (`mixed`, so + * literally whatever the client sent), a structure too deep or too circular to walk, or an object whose + * own accessor threw mid-encode. The open namespace is therefore RENDERED, not deleted: encodable() + * replaces a value json_encode could refuse with the name of its type and keeps every value that was + * never in question, so `allowed` — which ProblemMapper itself publishes as an extension on a 405 — + * still lists the verbs, and a `balance` that cannot be carried says `App\Models\Balance` instead of + * vanishing. Deleting the namespace wholesale is what made a degraded document unreadable AND + * indistinguishable; ConfigPropsEndpoint::value() has rendered the unencodable as its type since it + * shipped, for the same stated reason, and one framework should answer one question one way. + * `errors` keeps its four declared-string members and drops the one `mixed` one — see fieldErrors(), + * which is where the difference between an operator's context and a sentence shown to a person is + * argued. The standard members STAY, because ErrorResponse declares every one of them `?string` + * (`int`, for the status) and array_diff_key() keeps a same-named extension out of the document, so + * not one of them can be why we are here and dropping them buys nothing: * * - `category` and `severity` pinned to Internal/Error for every exception publishes a document that * contradicts its own status and code — a 409 whose category reads `internal`, a 422 a generated @@ -220,13 +307,21 @@ private static function minimal(array $payload): string } } - // LAST, where STANDARD_MEMBERS has it, so the degraded document still reads like the full one. + // LAST of the standard members, where STANDARD_MEMBERS has it, so the degraded document still reads + // like the full one. $errors = self::fieldErrors($payload); if ($errors !== []) { $document['errors'] = $errors; } + // AND THE OPEN NAMESPACE AFTER THEM, which is where ErrorResponse::toArray() puts it: the standard + // members lead every problem document this framework publishes and the extensions follow, so the + // degraded one diffs against the full one member for member rather than looking like a third shape. + foreach (self::extensions($payload) as $name => $value) { + $document[$name] = $value; + } + $json = json_encode($document, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); return is_string($json) @@ -234,6 +329,90 @@ private static function minimal(array $payload): string : '{"status":500,"title":"Internal Server Error","code":"INTERNAL_ERROR","category":"internal","severity":"error"}'; } + /** + * The RFC 9457 open namespace of the payload — every member that is not one ErrorResponse defines — + * with each value rendered as something json_encode cannot refuse. + * + * THE NAMES ARE THE POINT. An application puts context on an exception at the throw site precisely + * because that context is what makes the failure legible — the tenant, the order id, the upstream it + * called — and for one release this method did not exist and the whole namespace was deleted the moment + * ANY member of the document failed to encode. One unreadable `balance` took `tenant` and `orderId` + * with it, and took the framework's OWN extension with it too: ProblemMapper publishes a 405's verb + * list as `allowed`, a plain list of strings that can never be why an encode failed. Keeping the + * members and rendering the values is strictly more truthful than dropping them, and strictly less + * disclosure than the healthy document already published — an object that would have been serialised + * with every property it owns is named by its class and nothing else. + * + * @param array $payload + * @return array + */ + private static function extensions(array $payload): array + { + $members = []; + + foreach (array_diff_key($payload, array_flip(ErrorResponse::STANDARD_MEMBERS)) as $name => $value) { + $members[$name] = self::encodable($value); + } + + return $members; + } + + /** + * One value of the open namespace, rendered as something json_encode cannot refuse and — the half that + * matters here — cannot have to ASK THE APPLICATION about. + * + * TOTALITY IS THE WHOLE REQUIREMENT, and it is why this walks types rather than trying values. Scalars + * and null are already JSON. Arrays are descended, to a bound, because a circular one is reachable + * through a reference and an unbounded walk would exchange a failed encode for a failed stack. A + * non-finite float is NAMED rather than typed: get_debug_type() would say `float`, which is the single + * least interesting true thing about an INF, and an INF in an extension member is not hypothetical — + * json_decode('{"ratio": 1e999}') is exactly that, so any caller can post one. Enums answer from their + * own case, which is a property read and not a method call. EVERYTHING ELSE — an object, a resource, a + * closure — becomes get_debug_type(), and get_debug_type() is the only answer available: asking an + * object for its value is calling the application's code, and calling the application's code is what + * threw on the way in here. That is ConfigPropsEndpoint::value()'s reasoning and very nearly its + * shape, minus the one branch it has that this cannot have — it descends into an object's properties, + * and it may, because the objects it walks are config DTOs the framework built itself. + */ + private static function encodable(mixed $value, int $depth = 0): mixed + { + if ($value === null || is_bool($value) || is_int($value) || is_string($value)) { + return $value; + } + + if (is_float($value)) { + if (is_finite($value)) { + return $value; + } + + return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF'); + } + + if (is_array($value)) { + if ($depth > self::MAX_DEPTH) { + return 'array'; + } + + $mapped = []; + + foreach ($value as $key => $item) { + $mapped[$key] = self::encodable($item, $depth + 1); + } + + return $mapped; + } + + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof UnitEnum) { + return $value->name; + } + + return get_debug_type($value); + } + /** * The field errors of the payload, rebuilt from the members FieldError DECLARES to be strings. * @@ -247,6 +426,15 @@ private static function minimal(array $payload): string * field-error arm and then hands that arm nothing. The rejected value goes. The field, the sentence, * the code and the constraint stay. * + * AND THE REJECTED VALUE GOES RATHER THAN BEING NAMED, which is the opposite of what happens to an + * extension member one line above, on purpose. `rejectedValue` is an ECHO: its whole contract is "this + * is the value you sent", and a client renders it back to the person who sent it. Writing `"float"` + * there would not be a degraded truth, it would be a false sentence shown to a human — nobody posted + * the word float. An extension member has no such contract: it is context an application chose for an + * OPERATOR, and naming its type is the most useful thing that can honestly be said about a value + * nobody can read. An absent `rejectedValue` is already a shape every client handles, because + * FieldError declares it nullable and omits it when there is none. + * * Every member is type-checked rather than trusted, for minimal()'s reason: we are on this path * precisely because the payload was not the shape it claims to be. An entry that is not an array, or * one that keeps no member at all, contributes nothing instead of contributing an empty object — and diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 98a26ec3..fdb73a89 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -35,6 +35,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\Request; +use Psr\Log\LoggerInterface; use Throwable; /** @@ -64,9 +65,26 @@ public function passes(): array private function registerBindings(): void { if (! $this->app->bound(ProblemDetailsRenderer::class)) { - $this->app->singleton(ProblemDetailsRenderer::class, static fn (Container $app): ProblemDetailsRenderer => new ProblemDetailsRenderer( - $app->make(ErrorPageSettings::class), - )); + $this->app->singleton(ProblemDetailsRenderer::class, static function (Container $app): ProblemDetailsRenderer { + // The logger is what makes a DEGRADED problem document observable: when the encoder falls + // back, the throwable it caught is an arbitrary application exception, and without this + // argument it is recorded in no place at all. Optional and resolved defensively for the + // ResponseFactory's reason two closures down — Laravel aliases Psr\Log\LoggerInterface to + // the concrete 'log' key in registerCoreContainerAliases() whether or not LogServiceProvider + // ever registered anything, so bound() on the CONTRACT answers true in a bare container and + // the make() then throws. A logger that cannot be made is the same as none bound, and this + // renderer must not be the binding that fails to construct on an error path. + $logger = null; + if ($app->bound('log')) { + try { + $logger = $app->make(LoggerInterface::class); + } catch (BindingResolutionException) { + // No log manager: the document is still rendered, and the degradation is unwitnessed. + } + } + + return new ProblemDetailsRenderer($app->make(ErrorPageSettings::class), $logger); + }); } if (! $this->app->bound(ErrorPageSettings::class)) { diff --git a/packages/web/tests/CapstoneWebIntegrationTest.php b/packages/web/tests/CapstoneWebIntegrationTest.php index 6c0b69da..a85f249d 100644 --- a/packages/web/tests/CapstoneWebIntegrationTest.php +++ b/packages/web/tests/CapstoneWebIntegrationTest.php @@ -4,6 +4,8 @@ use Firefly\Web\Error\ProblemMapper; use Firefly\Web\Tests\Support\WebCapstoneTestCase; +use Illuminate\Support\Facades\Log; +use Psr\Log\AbstractLogger; use Symfony\Component\HttpFoundation\Response; uses(WebCapstoneTestCase::class); @@ -210,7 +212,9 @@ ->assertJsonPath('category', 'business') ->assertJsonPath('severity', 'warning') ->assertJsonPath('detail', 'The ledger disagrees.') - ->assertJsonMissingPath('ratio'); + // The member stays and says what it holds. It used to be deleted, which published a degraded + // document that a healthy one could not be told apart from. + ->assertJsonPath('ratio', 'INF'); /** @var array $payload */ $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); @@ -232,8 +236,56 @@ ->assertJsonPath('code', 'LEDGER_CONFLICT') ->assertJsonPath('category', 'business') ->assertJsonPath('detail', 'The ledger disagrees.') - ->assertJsonMissingPath('balance'); + // Named rather than deleted, which is get_debug_type()'s answer and ConfigPropsEndpoint's idiom. + ->assertJsonPath('balance', 'JsonSerializable@anonymous'); // The thrower's own sentence is an internal detail and must not ride out on the document either. expect((string) $response->getContent())->not->toContain('accessor could not read'); }); + +/* + * AND THE SWALLOWED THROWABLE REACHES THE LOG, THROUGH THE WIRING AND NOT THROUGH A CONSTRUCTOR ARGUMENT. + * The renderer takes an optional logger, and a unit test can hand it one; what a unit test cannot prove is + * that the application's own logger ever arrives — WebServiceProvider builds this singleton itself, and for + * one release it passed only the settings object, so the reporting arm would have been dead in every real + * boot while every unit test around it stayed green. The failure below is the one the reviewer reproduced: + * a RuntimeException raised by an extension member's accessor, recorded in no place at all. + */ +it('reports the degraded problem document through the application logger the provider wired', function () { + /** @var WebCapstoneTestCase $this */ + $log = new class extends AbstractLogger + { + /** @var list}> */ + public array $lines = []; + + /** + * @param array $context + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $this->lines[] = ['message' => (string) $message, 'context' => $context]; + } + }; + + // Swapped BEFORE the request, which is when the renderer singleton is first resolved: the provider's + // closure reads the container's logger at construction time, so this is the application's logger as + // far as it is concerned. + Log::swap($log); + + $response = $this->getJson('/errors/throwing-extension'); + $response->assertStatus(409); + + $degraded = array_values(array_filter( + $log->lines, + static fn (array $line): bool => str_contains($line['message'], 'degraded'), + )); + + $cause = $degraded[0]['context']['exception'] ?? null; + + expect($degraded)->toHaveCount(1) + // The cause, which nothing anywhere recorded before. + ->and($cause)->toBeInstanceOf(Throwable::class) + ->and($cause instanceof Throwable ? $cause->getMessage() : '')->toContain('the accessor could not read the balance') + // And the reference the caller is holding, so the two ends of the failure join up. + ->and($degraded[0]['context']['reference'])->toBe($response->headers->get('X-Correlation-Id')); +}); diff --git a/packages/web/tests/Exception/ProblemDetailsRendererTest.php b/packages/web/tests/Exception/ProblemDetailsRendererTest.php index ac2e9f1c..5632b3aa 100644 --- a/packages/web/tests/Exception/ProblemDetailsRendererTest.php +++ b/packages/web/tests/Exception/ProblemDetailsRendererTest.php @@ -17,6 +17,7 @@ use Illuminate\Config\Repository; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; +use Psr\Log\AbstractLogger; use Symfony\Component\ErrorHandler\Error\FatalError; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -331,8 +332,143 @@ ->and($payload['correlationId'])->toBe('corr-77') ->and($payload['instance'])->toBe('api/x') ->and($payload['timestamp'])->toBeString() - // The member that could not be encoded is simply not there; it is not a reason to answer nothing. - ->and($payload)->not->toHaveKey('ratio'); + // THE MEMBER STAYS AND SAYS WHAT IT IS. Deleting it published a document a healthy one could not + // be told apart from, and took every OTHER extension member down with it — including `allowed`, + // which ProblemMapper itself publishes on a 405. `INF` rather than `float`, because `float` is the + // one true thing about this value that helps nobody. + ->and($payload['ratio'])->toBe('INF') + // And it is LAST, after `timestamp`, exactly where ErrorResponse::toArray() puts the open + // namespace in a healthy document. + ->and(array_key_last($payload))->toBe('ratio'); +}); + +it('keeps every extension member that was never in question, and names only the one that was', function () { + // The real shape of the failure: an application hangs three pieces of context off a throw site and + // exactly ONE of them is unencodable. Dropping the namespace wholesale lost the tenant and the order + // — the two members that make the failure legible — to pay for the one nobody could read. + $impossible = (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions([ + 'tenant' => 'acme', + 'attempts' => 3, + 'allowed' => ['GET', 'POST'], + 'balance' => fopen('php://memory', 'r'), + ]); + + $response = (new ProblemDetailsRenderer)->render($impossible, Request::create('/api/x')); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + expect($payload['tenant'])->toBe('acme') + ->and($payload['attempts'])->toBe(3) + ->and($payload['allowed'])->toBe(['GET', 'POST']) + // get_debug_type()'s spelling, which is ConfigPropsEndpoint::value()'s answer to the same question: + // naming the type never calls the value's own code, and calling the value's own code is what threw. + ->and($payload['balance'])->toBe('resource (stream)'); +}); + +it('reports the throwable it swallowed, with the document\'s own reference, when an extension member cannot be encoded', function () { + // THE ARM THAT USED TO FAIL UNOBSERVABLY. `catch (Throwable) { $json = false; }` put an arbitrary + // application exception into a pair of braces and published a degraded document with no marker, no + // header and no log line — so an operator whose application was losing its extension members from + // every problem document it published had no way to learn that it was happening, or why. + $log = new class extends AbstractLogger + { + /** @var list}> */ + public array $lines = []; + + /** + * @param array $context + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $this->lines[] = ['level' => $level, 'message' => (string) $message, 'context' => $context]; + } + }; + + $thrower = new class implements JsonSerializable + { + public function jsonSerialize(): mixed + { + throw new RuntimeException('the accessor could not read the balance either'); + } + }; + + $request = Request::create('/api/x'); + $request->headers->set(CorrelationIdFilter::HEADER, 'corr-99'); + + $renderer = new ProblemDetailsRenderer(new ErrorPageSettings, $log); + $response = $renderer->render( + (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['balance' => $thrower]), + $request, + ); + + $cause = $log->lines[0]['context']['exception'] ?? null; + + expect($log->lines)->toHaveCount(1) + ->and($log->lines[0]['level'])->toBe('error') + ->and($log->lines[0]['message'])->toContain('degraded') + // The cause itself, under the key PSR-3 reserves for it — so the class, the sentence and the trace + // all reach the log, and none of them reaches the caller. + ->and($cause)->toBeInstanceOf(Throwable::class) + ->and($cause instanceof Throwable ? $cause->getMessage() : '')->toContain('the accessor could not read the balance') + // And the document's own reference, which is the only thing that joins this line to the body a + // person is holding. A log line an operator cannot reach from the response is half a record. + ->and($log->lines[0]['context']['reference'])->toBe('corr-99') + ->and(json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR)) + ->toHaveKey('traceId', 'corr-99'); +}); + +it('says nothing, and still answers, when the document encodes cleanly', function () { + $log = new class extends AbstractLogger + { + /** @var list */ + public array $lines = []; + + /** + * @param array $context + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $this->lines[] = (string) $message; + } + }; + + $response = (new ProblemDetailsRenderer(new ErrorPageSettings, $log)) + ->render(new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'), Request::create('/api/x')); + + // The happy path is the overwhelmingly common one: a line per rendered problem would drown the line + // that means something. + expect($log->lines)->toBe([]) + ->and($response->getStatusCode())->toBe(409); +}); + +it('still answers with a document when the logger itself throws', function () { + // The fix must not reintroduce the bug one layer up. The logger is a live collaborator on a failing + // box — a full disk, a channel whose endpoint is the thing that went down — and a throw from the + // reporting call would escape render() and produce exactly the blank 500 the encoder was made total + // to prevent. + $log = new class extends AbstractLogger + { + /** + * @param array $context + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + throw new RuntimeException('the log channel is down too'); + } + }; + + $response = (new ProblemDetailsRenderer(new ErrorPageSettings, $log))->render( + (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['ratio' => INF]), + Request::create('/api/x'), + ); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + expect($response->getStatusCode())->toBe(409) + ->and($payload['code'])->toBe('LEDGER_CONFLICT') + ->and($payload['ratio'])->toBe('INF'); }); it('falls back to a minimal document when an extension member\'s own jsonSerialize() throws', function () { @@ -368,7 +504,10 @@ public function jsonSerialize(): mixed ->and($payload['detail'])->toBe('The ledger disagrees.') // Still correlatable, which is the one action the degraded document exists to keep possible. ->and($payload['traceId'])->toBe('corr-88') - ->and($payload)->not->toHaveKey('balance'); + // The member is NAMED, not deleted — get_debug_type()'s answer for an anonymous class is the + // interface it implements. What the thrower SAID is an internal detail and stays out. + ->and($payload['balance'])->toBe('JsonSerializable@anonymous') + ->and((string) $response->getContent())->not->toContain('accessor could not read'); }); it('keeps the field errors, and drops only the rejected value, when a rejected value cannot be encoded', function () { @@ -419,3 +558,26 @@ public function jsonSerialize(): mixed expect(json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR))->toBeArray() ->and($response->getStatusCode())->toBe(409); }); + +it('answers an extension member that refers to itself, instead of recursing until the stack goes', function () { + // The one way keeping the open namespace could have swapped a failed ENCODE for a failed STACK: an + // array that holds itself through a reference is what json_encode reports as "Recursion detected", and + // a walk of it with no bound would never come back at all. The depth cap is what makes the walk total. + $cycle = ['tenant' => 'acme']; + $cycle['self'] = &$cycle; + + $response = (new ProblemDetailsRenderer)->render( + (new ConflictException('The ledger disagrees.', 'LEDGER_CONFLICT'))->withExtensions(['context' => $cycle]), + Request::create('/api/x'), + ); + + /** @var array $payload */ + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $context = $payload['context'] ?? null; + + // The document comes back, the member is there, and the readable part of it survived the trip down. + expect($response->getStatusCode())->toBe(409) + ->and($payload['code'])->toBe('LEDGER_CONFLICT') + ->and($context)->toBeArray() + ->and(is_array($context) ? $context['tenant'] : null)->toBe('acme'); +}); From adbcec2daf81a15f89309ec8c20120450b5f5f32 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 20:12:36 -0700 Subject: [PATCH 49/78] fix(web): a caller that sent a wildcard or no Accept header gets problem+json instead of Laravel's page --- packages/web/src/Error/ErrorPageRenderer.php | 32 ++++++++++ packages/web/src/Error/ErrorPageSettings.php | 11 ++++ packages/web/src/WebServiceProvider.php | 16 +++-- packages/web/tests/Error/ErrorPageTest.php | 65 ++++++++++++++++++++ skeleton/config/firefly.php | 12 ++++ 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php index 027fdd7d..2052a560 100644 --- a/packages/web/src/Error/ErrorPageRenderer.php +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeInterface; +use Firefly\Kernel\Exception\FireflyException; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -95,6 +96,37 @@ public function forcesJson(Request $request): bool return $this->settings->enabled && $this->settings->isJsonPath($request->path()); } + /** + * Whether this failure is answered with a problem document — the WHOLE of that decision, asked after + * `handles()` has already answered "is this a page". + * + * THE BUG THIS FIXES. The rule used to live in WebServiceProvider as + * `$e instanceof FireflyException || $request->expectsJson() || $page->forcesJson($request)`, and its + * gap was the commonest client there is. A WILDCARD Accept header is what a bare `curl` sends and what + * `fetch()` sends by default; an absent Accept is what a hand-rolled client sends. Neither NAMES + * text/html, so neither got the page — and `expectsJson()` is false for both (`wantsJson()` tests the + * FIRST acceptable type, and a wildcard is not a JSON type; `ajax()` is false) — so a router 404 on a + * path outside `json-paths` fell all the way through to Laravel's stock HTML page. A client that asked + * for anything was handed markup, while the documentation had promised it a problem document since the + * page shipped. + * + * THE FALLBACK IS THE LAST TERM, NOT THE FIRST. A FireflyException, a JSON client and a `json-paths` URL + * are answered exactly as they were. Only the caller who expressed no preference changes, and only + * because "no preference" plus "not a browser" leaves one shape that anything can read. + * + * A BROWSER IS NEVER CAUGHT BY IT, INCLUDING WHEN THE PAGE IS OFF. `prefersHtml()` — not `handles()` — + * is the test, so `firefly.web.error-page.enabled => false`, whose documented meaning is "use Laravel's + * own error page", keeps meaning that instead of silently turning every browser 404 into JSON. + */ + public function rendersProblem(Throwable $e, Request $request): bool + { + if ($e instanceof FireflyException || $request->expectsJson() || $this->forcesJson($request)) { + return true; + } + + return $this->settings->problemFallback && ! $this->prefersHtml($request); + } + public function render(Throwable $e, Request $request): Response { $exception = ProblemMapper::toFireflyException($e); diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index feeb68a8..7512fe53 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -23,6 +23,13 @@ * status (or `default`) to the application's own Blade view, so a public 404 can be the product's own page * while a 500 in staging is still the framework's diagnostic one. * + * `problem-paths` IS NOT A KEY AND `problem-fallback` IS. The question `json-paths` answers is "which URLs + * are machine surfaces"; the question this one answers is "what does a caller who named nothing get". They + * are different questions and only the first is about the URL. With the fallback on — the default, and what + * the documentation has always claimed — a request carrying a WILDCARD Accept header, or no Accept at all, + * is answered with the problem document, because that is the form a client can read and the page is for a + * person who asked for one. Off, such a request falls through to Laravel's handler exactly as it used to. + * * `trace` DEFAULTS TO `app.debug` and is enforced at render time, not merely at template time — the renderer * builds no frame list, opens no source file and copies no exception message when it is off. That is * deliberate: a page that assembled the details and then declined to print them would put a stack trace one @@ -126,6 +133,9 @@ public function __construct( public bool $authoredDetail = true, // Progressive enhancement, and the only script this page has ever carried: see ErrorPage::clipboard(). public bool $copyButton = true, + // Whether a caller that named NOTHING acceptable gets a problem document rather than Laravel's own + // page. See ErrorPageRenderer::rendersProblem() for the case this closes. + public bool $problemFallback = true, ) { $this->home = self::url($home); $this->signIn = self::url($signIn); @@ -203,6 +213,7 @@ public static function fromConfig(Config $config): self maxFrames: max(1, min(500, $config->int('firefly.web.error-page.max-frames', 40))), authoredDetail: $config->bool('firefly.web.error-page.authored-detail', true), copyButton: $config->bool('firefly.web.error-page.copy-button', true), + problemFallback: $config->bool('firefly.web.error-page.problem-fallback', true), ); } diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index fdb73a89..19f6247f 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -8,7 +8,6 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; use Firefly\Context\Scan\AppScan; -use Firefly\Kernel\Exception\FireflyException; use Firefly\Validation\Constraint\BeanValidator; use Firefly\Validation\Constraint\ConstraintManifest; use Firefly\Validation\Constraint\ConstraintManifestCompiler; @@ -224,10 +223,15 @@ private function registerBindings(): void * * The order is the whole of it. A browser that names `text/html` gets the HTML page — which is what * fixes a person clicking a stale link and being shown a raw JSON blob, the behaviour every - * FireflyException had. Everything else keeps the previous rule exactly: a FireflyException, or a - * request that wants JSON, renders as problem+json. A throwable that is NEITHER — an unrouted URL hit by - * a client that asked for neither — still falls through to Laravel's handler, because inventing a - * response shape for a caller that expressed no preference is not this package's decision to make. + * FireflyException had. + * + * Everything else keeps the previous rule and gains the case it was missing: a FireflyException, a + * request that wants JSON and a `json-paths` URL all render as problem+json, and so now does a caller + * that named NOTHING — a wildcard Accept header from a bare curl, or no Accept at all — which used to + * fall through to Laravel's stock HTML page. The predicate itself lives in + * ErrorPageRenderer::rendersProblem(), beside handles() and prefersHtml(), because it is the same + * negotiation asked a third way; this provider keeps only the wiring. + * `firefly.web.error-page.problem-fallback => false` restores the fall-through. */ private function registerProblemDetailsRenderable(): void { @@ -243,7 +247,7 @@ private function registerProblemDetailsRenderable(): void return $page->render($e, $request); } - if ($e instanceof FireflyException || $request->expectsJson() || $page->forcesJson($request)) { + if ($page->rendersProblem($e, $request)) { return $this->app->make(ProblemDetailsRenderer::class)->render($e, $request); } diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index ae946c19..fbc8376a 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -1283,3 +1283,68 @@ ->toContain('Your trial ended on the 3rd.') ->not->toContain('You do not have access to that.'); }); + +it('answers a wildcard or an absent Accept with a problem document, not Laravel\'s page', function () { + // `Accept: */*` is what a bare curl sends and what fetch() sends by default, and an absent Accept is + // what a hand-rolled client sends. Neither NAMES text/html, so neither gets the page — and neither + // wanted Laravel's stock HTML either, which is what both used to receive for any non-FireflyException. + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: true)); + $routerMiss = new NotFoundHttpException('The route nope could not be found.'); + + $wildcard = Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => '*/*']); + // Request::create() PUTS a browser's Accept header on a request that was handed none — a harness + // convenience, and the opposite of what a hand-rolled client sends — so the absent case has to be + // made absent on purpose. The predicate reads the header bag, which is what is emptied here. + $absent = Request::create('/orders/9', 'GET'); + $absent->headers->remove('Accept'); + $absent->server->remove('HTTP_ACCEPT'); + $browser = Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => 'text/html,application/xhtml+xml']); + + expect($renderer->rendersProblem($routerMiss, $wildcard))->toBeTrue() + ->and($renderer->rendersProblem($routerMiss, $absent))->toBeTrue() + // A browser still gets the page: handles() is asked first, and this predicate agrees with it. + ->and($renderer->handles($browser))->toBeTrue() + ->and($renderer->rendersProblem($routerMiss, $browser))->toBeFalse(); +}); + +it('keeps every branch the renderable already had', function () { + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: true, jsonPaths: ['api/*'])); + $browser = ['HTTP_ACCEPT' => 'text/html,application/xhtml+xml']; + $fromABrowser = Request::create('/orders/9', 'GET', server: $browser); + + // A FireflyException is a problem document to THIS predicate whoever asked — the first term of the + // boolean this method replaced, carried over unchanged. The browser still gets the PAGE, because the + // renderable asks handles() first and never reaches here, and that is asserted on the line below + // rather than left to a comment. Answering false here instead would be a second, silent change of + // behaviour: with `firefly.web.error-page.enabled => false` a browser hitting a FireflyException would + // stop receiving problem+json and start receiving Laravel's stock page, which no key here asked for. + expect($renderer->handles($fromABrowser))->toBeTrue() + ->and($renderer->rendersProblem(new ResourceNotFoundException('x', 'X'), $fromABrowser))->toBeTrue() + ->and($renderer->rendersProblem(new ResourceNotFoundException('x', 'X'), Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => 'application/json'])))->toBeTrue() + // json-paths still overrides the header in both directions. + ->and($renderer->rendersProblem(new NotFoundHttpException, Request::create('/api/nope', 'GET', server: $browser)))->toBeTrue() + // An XMLHttpRequest that names text/html is JavaScript about to read a body. + ->and($renderer->rendersProblem(new NotFoundHttpException, Request::create('/orders/9', 'GET', server: [...$browser, 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'])))->toBeTrue(); +}); + +it('leaves a caller that expressed no preference to Laravel when the fallback is switched off', function () { + // The escape hatch for an application that has its own opinion about an unrouted URL: with the key off, + // a caller who named nothing falls through exactly as it did before, while a FireflyException, a JSON + // client and an api/* path are all unaffected. + $off = new ErrorPageRenderer(new ErrorPageSettings(enabled: true, jsonPaths: ['api/*'], problemFallback: false)); + $wildcard = Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => '*/*']); + + expect($off->rendersProblem(new NotFoundHttpException, $wildcard))->toBeFalse() + ->and($off->rendersProblem(new ResourceNotFoundException('x', 'X'), $wildcard))->toBeTrue() + ->and($off->rendersProblem(new NotFoundHttpException, Request::create('/api/nope', 'GET')))->toBeTrue(); +}); + +it('claims nothing at all when the page is switched off and the caller is a browser', function () { + // `enabled: false` means "use Laravel's stock error page", and it must keep meaning that: a browser + // gets Laravel's page, and the fallback does not quietly turn it into JSON. + $off = new ErrorPageRenderer(new ErrorPageSettings(enabled: false)); + $browser = Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => 'text/html']); + + expect($off->handles($browser))->toBeFalse() + ->and($off->rendersProblem(new NotFoundHttpException, $browser))->toBeFalse(); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 680a2be5..7357f193 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1045,6 +1045,18 @@ // 'copy-button' => env('FIREFLY_WEB_ERROR_PAGE_COPY_BUTTON', true), // // /* + // | Whether a caller that named NOTHING acceptable gets `application/problem+json` rather than + // | falling through to Laravel's own error page. `Accept: */*` is what a bare curl and a + // | default fetch() send, and an absent Accept is what a hand-rolled client sends; neither + // | names text/html, so neither is a browser, and both used to receive framework HTML for any + // | non-FireflyException. A browser is never caught by this — it is tested by `prefersHtml()`, + // | so `enabled => false` keeps meaning "use Laravel's page". + // | + // | Default: true. + // */ + // 'problem-fallback' => env('FIREFLY_WEB_ERROR_PAGE_PROBLEM_FALLBACK', true), + // + // /* // | CSV of path patterns that answer with `application/problem+json` WHATEVER the caller's // | Accept header says. Checked BEFORE the header, because the header says who is asking and // | the path says what the URL is. From 6cb902b4dc5a3196cc7d74f7c3c72c473605a644 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 20:23:35 -0700 Subject: [PATCH 50/78] fix(admin): size the data browser's datetime column, give its page-size key back its meaning, and choose the in-PHP tiebreak per column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in the commit that moved the data browser onto the shared table system, all of them silent: The computed from the schema gave a datetime column the same `calc(13ch + 2 * var(--row-x))` as an int, a float and a boolean. Under the `table-layout:fixed` that commit introduced a cell cannot grow out of its column, only clip — and `2026-01-01 10:00:00` is nineteen characters, so the stamp's own span measured 143px of text inside a 98px box and the operator read `2026-01-01 10…` as the whole instant. Nineteen is the width `TableColumn::stamp()` already carries for exactly this string, so the two mechanisms that size a timestamp on this dashboard now agree rather than each guessing. `firefly.admin.data.page-size` had become inert for the dashboard. The page stated `$query->size` on every call to `DataBrowser::list()`, so `clampPageSize()`'s "null means use the configured default" branch was unreachable from the web UI and a browser documented at 25 rows served 50. The two bounds are now composed BEFORE the request is parsed, by a new `TableSettings::boundedBy()`: the query falls back to the browser's own default, the rows-per-page control offers the shared set narrowed by `max-page-size` with `page-size` forced into it, and a link omits `size` when it is that default — so a deployment that asked for ten rows gets ten rows AND an option that says so. `ListingQuery::sized()` records the size the rows were SERVED at, which the offered set never chose, so the rows-per-page ` - @foreach ($query->settings->pageSizes as $size) + {{-- offering(), NOT pageSizes: the data browser's own `max-page-size` caps where the + offered set refuses, so the rows can be served at a size the set does not contain. A + diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index c66a3e3e..ff765cc2 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -159,7 +159,22 @@ text and json columns share what is left — which is where a reader needs it. --}} @foreach ($columns as $column) - + @php + // A STAMP IS NOT THIRTEEN CHARACTERS WIDE. Every rigid type here has a + // known maximum, but they do not share one: `2026-01-01 10:00:00` is + // NINETEEN, which is the number `TableColumn::stamp()` already carries + // for exactly this string, so the two mechanisms that size a timestamp + // on this dashboard agree instead of each guessing. Thirteen is what an + // int, a float and a boolean need, and at thirteen the stamp's own span + // measures 143px of text inside a 98px box — under `table-layout:fixed` + // the operator then reads `2026-01-01 10…` as the whole instant. + $width = match ($column->type) { + DataColumn::TYPE_DATETIME => 'calc(19ch + 2 * var(--row-x))', + DataColumn::TYPE_INT, DataColumn::TYPE_FLOAT, DataColumn::TYPE_BOOL => 'calc(13ch + 2 * var(--row-x))', + default => 'auto', + }; + @endphp + @endforeach @if ($identifier !== null)@endif diff --git a/packages/admin/src/Data/DataBrowserSettings.php b/packages/admin/src/Data/DataBrowserSettings.php index 7734f88e..16be978a 100644 --- a/packages/admin/src/Data/DataBrowserSettings.php +++ b/packages/admin/src/Data/DataBrowserSettings.php @@ -91,7 +91,17 @@ public function allows(string $slug): bool return ! in_array($slug, $this->excluded, true); } - /** Clamp a caller-supplied page size into [1, maxPageSize]; null means "use the configured default". */ + /** + * Clamp a caller-supplied page size into [1, maxPageSize]; null means "use the configured default". + * + * THE DASHBOARD DOES NOT ARRIVE HERE WITH A SURPRISE. Its listing query is parsed against + * `Table\TableSettings::boundedBy($this->pageSize, $this->maxPageSize)`, so the size it hands `list()` + * is already inside both bounds and this returns it unchanged — which is exactly what lets the + * rows-per-page control offer the sizes the listing can actually serve, and what lets `page-size` decide + * the default there rather than being shadowed by a size the page states on every call. Both bounds + * still bite for a direct `DataBrowser::list()` call, which has no query to have been parsed against + * them and may ask for anything. + */ public function clampPageSize(?int $requested): int { if ($requested === null) { diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php index 3cbbd039..678d84cf 100644 --- a/packages/admin/src/Data/DataQueryEngine.php +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -345,10 +345,23 @@ private function fetchInPhp( // ARRAY it is stabilising is rebuilt from the repository on every request, so stability of the // sort is not stability of the page boundary. See sort() above. Like the empty rank above it, // and for the same reason, it sits OUTSIDE the direction flip: an identity is not a second - // ordering. + // ordering. It is appended only when the identifier is not already the sort column, for the + // same reason `ORDER BY id DESC, id ASC` is not emitted. + // + // THE TIEBREAK IS A COLUMN TOO, AND IS CHOSEN THE SAME WAY — `forColumn()` over everything the + // identifier column holds, never `compare()` pair by pair. `Table\InMemoryListing::order()` + // does exactly this beside it, and RowComparator says why: a per-pair choice between the + // arithmetic and the natural comparison can close a cycle (`1.10 < 1.9 < 1.9-beta < 1.10` on a + // column of versions), and an ordering that is not a strict weak ordering entitles `usort()` to + // answer anything at all. An inconsistent tiebreak breaks the whole ordering just as thoroughly + // as an inconsistent primary, which is the instability this branch exists to remove. $tiebreak = $schema->identifier; + $breakTie = $tiebreak === null || $tiebreak === $sort ? null : RowComparator::forColumn(array_map( + static fn (array $row): mixed => $row['values'][$tiebreak] ?? null, + $matched, + )); - usort($matched, static function (array $a, array $b) use ($sort, $direction, $compare, $tiebreak): int { + usort($matched, static function (array $a, array $b) use ($sort, $direction, $compare, $tiebreak, $breakTie): int { $left = $a['values'][$sort] ?? null; $right = $b['values'][$sort] ?? null; @@ -364,9 +377,11 @@ private function fetchInPhp( $comparison = -$comparison; } - return $comparison !== 0 || $tiebreak === null || $tiebreak === $sort + // `$breakTie` is null for exactly the two cases that have no tiebreak to apply — no + // identifier, or an identifier that IS the sort column — so testing it tests both. + return $comparison !== 0 || $breakTie === null ? $comparison - : RowComparator::compare($a['values'][$tiebreak] ?? null, $b['values'][$tiebreak] ?? null); + : $breakTie($a['values'][$tiebreak] ?? null, $b['values'][$tiebreak] ?? null); }); } diff --git a/packages/admin/src/Table/ListingQuery.php b/packages/admin/src/Table/ListingQuery.php index ca479d41..65d4eb17 100644 --- a/packages/admin/src/Table/ListingQuery.php +++ b/packages/admin/src/Table/ListingQuery.php @@ -224,16 +224,19 @@ public function carrying(array $parameters): self /** * The same listing at the size it was actually served. * - * THIS EXISTS FOR A SECOND BOUND DOWNSTREAM. Every actuator listing is sliced by `InMemoryListing` at - * exactly `$size`, so for those this is never called. The data browser is not: `DataBrowser::list()` - * applies `firefly.admin.data.max-page-size` on top of the table's own offered set, because a page size - * that is merely large on an actuator payload can materialise a whole table into PHP memory on a - * repository that cannot page. When that cap is the tighter of the two, the rows come back at ITS size - * while every number a pager draws — the range readout, the last page, whether `Next` is live — would - * still be computed from the size that was asked for: on `table.page-size=100` over - * `data.max-page-size=50` the pager would claim half as many pages as there are and disable `Next` with - * the second half of the table still unreached. So the query is re-stated at the size that was served, - * and every link it then writes carries that size rather than the one the bound refused. + * THIS EXISTS BECAUSE ONE SIDE ASKS AND THE OTHER SERVES. Every actuator listing is sliced by + * `InMemoryListing` at exactly `$size`, so for those this is never called. The data browser is the + * caller: `DataBrowser::list()` applies `firefly.admin.data.max-page-size` to whatever it is handed, and + * a page that merely ASSUMED the two agree would be assuming something it cannot see from the outside. + * `TableSettings::boundedBy()` composes that cap into the settings this query was parsed against + * precisely so they do agree, and this is the line that makes the agreement a fact rather than a hope. + * Were they ever to part, every number a pager draws — the range readout, the last page, whether `Next` + * is live — would still be computed from the size that was refused: over a served 50 a query stating 100 + * claims half as many pages as there are and disables `Next` with the second half of the table + * unreached. So the query is re-stated at the size that was served, and every link it then writes + * carries that size rather than the one it asked for. A re-stated size is the one size on this object + * that the offered set did not choose, which is why the rows-per-page control renders + * `TableSettings::offering()` rather than the set itself. */ public function sized(int $size): self { diff --git a/packages/admin/src/Table/TableSettings.php b/packages/admin/src/Table/TableSettings.php index a8926af7..5b18942a 100644 --- a/packages/admin/src/Table/TableSettings.php +++ b/packages/admin/src/Table/TableSettings.php @@ -14,7 +14,9 @@ * 19 999 rows" are a cap or a refusal — a cap silently renders a page nobody asked for, so this refuses and * falls back to the configured default. The set is also what the rows-per-page `` whose current value has no `` is the whole story — a cell + * cannot grow out of it, it can only clip — so a column sized for the wrong alphabet is a value the operator + * never sees. `2026-01-01 10:00:00` is NINETEEN characters, which is what `TableColumn::stamp()` already + * uses for the same string; an int, a float and a boolean need thirteen. Sized alike at thirteen, the stamp's + * own span measures 143px of text inside a 98px box and the cell reads `2026-01-01 10…`. + * + * The count is the assertion because a `` carries no class: this fixture has three columns that take + * the narrow width (`id`, `amount`, `active`) and exactly one datetime (`created_at`). + */ +it('sizes a datetime column for the whole timestamp and the numeric ones for a figure', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminRecords(); + + $html = (string) $this->get('/firefly/data?resource=admin-record')->assertStatus(200)->getContent(); + + expect(substr_count($html, 'calc(19ch + 2 * var(--row-x))'))->toBe(1) + ->and(substr_count($html, 'calc(13ch + 2 * var(--row-x))'))->toBe(3) + // And the value it has to hold really is the full instant, seconds included. + ->and($html)->toContain('2026-01-01 10:00:00'); +}); + +/** + * ROWS PER PAGE HERE IS THE BROWSER'S OWN KEY, and it stopped being that the moment this page started + * stating a size on every call: `DataBrowser::list()` was handed `$query->size` — the dashboard-wide + * `firefly.admin.table.page-size`, 50 — so `DataBrowserSettings::clampPageSize()`'s "null means use the + * configured default" branch became unreachable from the web UI and a browser documented at 25 rows silently + * served 50. `TableSettings::boundedBy()` composes the browser's pair into the shared settings before the + * request is parsed, which is what puts the key back in charge of the listing AND keeps the rows-per-page + * control showing where it is. + */ +it('pages the listing at the data browser\'s own default rather than the shared table default', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminRecords(); + + $rows = []; + foreach (range(2, 30) as $n) { + $rows[] = ['id' => $n, 'email' => 'row-'.$n.'@example.test', 'amount' => $n, 'active' => 1, 'created_at' => null]; + } + DB::table('admin_records')->insert($rows); + + $html = (string) $this->get('/firefly/data?resource=admin-record')->assertStatus(200)->getContent(); + + expect($html)->toContain('30 total') + ->toContain('1–25 of 30') + ->toContain('page 1 of 2') + // The control says which size it is at, and the shared set is still what it offers. + ->toContain('') + ->toContain(''); +}); diff --git a/packages/admin/tests/Support/DataBrowserPageSizeTestCase.php b/packages/admin/tests/Support/DataBrowserPageSizeTestCase.php new file mode 100644 index 00000000..e3c994c0 --- /dev/null +++ b/packages/admin/tests/Support/DataBrowserPageSizeTestCase.php @@ -0,0 +1,32 @@ +` unable to say where it is. + * + * Seeded through configOverrides() and NOT with a `config()->set()` inside the test, for the reason + * AdminTableScrollportOffTestCase already documents: AdminSettings and the DataBrowser are built during the + * boot passes and bound as instances, so a set from a test body arrives after the objects that would have + * read it. + */ +abstract class DataBrowserPageSizeTestCase extends DataBrowserTestCase +{ + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.data.page-size' => 10, + ]; + } +} diff --git a/packages/admin/tests/Table/ListingQueryTest.php b/packages/admin/tests/Table/ListingQueryTest.php index d2338ec5..ce1c5df1 100644 --- a/packages/admin/tests/Table/ListingQueryTest.php +++ b/packages/admin/tests/Table/ListingQueryTest.php @@ -203,3 +203,20 @@ function listingQuery(Request $request, string ...$sortable): ListingQuery ->and($served->link())->toBe('/firefly/mappings?size=25&page=3') ->and($query->sized(200))->toBe($query); }); + +/** + * A SERVED SIZE IS THE ONE SIZE THE OFFERED SET DID NOT CHOOSE. `clamp()` cannot return 30, so nothing this + * class parses is ever outside the set — but `sized()` does not parse, it RECORDS, and what it records is + * whatever the source served. The rows-per-page control therefore renders `TableSettings::offering()` + * rather than the set itself: a whose current value has no ` is computed from the resource's SCHEMA + * rather than from a hand-written view model, and it sized four types alike: an int, a float, a boolean and + * a datetime all took thirteen characters. Thirteen is right for the first three and wrong for the fourth — + * `2026-09-23 12:00:00` is nineteen, the width `TableColumn::stamp()` already carries for exactly this + * string — so under the `table-layout:fixed` this listing joined, the stamp's own `` + * reported 143px of text inside a 98px box and the operator read `2026-09-23 12…` as the whole instant. + * + * It measures the value the page really drew rather than substituting one, because a seeded order carries a + * real `created_at` written by Eloquent: the full instant IS the ordinary content of this column, not a + * worst case that has to be arranged. The assertion is the span and not the cell — `td.cell .v` is the + * clipping box here, and a cell that fits while its span does not is exactly the bug. + */ +it('holds a full timestamp in a data-browser datetime column', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + $this->seedOrders(); + + visit('/firefly/data?resource=order-entity') + ->assertScript(<<<'JS' + (() => { + const cell = document.querySelector('table.datatable td.t-datetime'); + if (cell === null) { return 'no datetime column on the order listing'; } + + const value = cell.querySelector('.v'); + if (value === null) { return 'the datetime cell drew no value: ' + cell.textContent.trim(); } + + const drawn = value.textContent.trim(); + if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(drawn)) { + return 'the cell does not hold a full instant: ' + drawn; + } + + const clipped = value.scrollWidth - value.clientWidth; + return clipped <= 0 + || 'the datetime column clips ' + drawn + ' by ' + clipped + 'px' + + ' (span ' + value.scrollWidth + '/' + value.clientWidth + ')'; + })() + JS, true) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'admin-data-datetime-column'); +}); From 6ece7490765da0a772a8f452b1134c2ea91cb914 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 20:35:05 -0700 Subject: [PATCH 51/78] fix(web): stand aside for the three throwables Laravel resolves itself, and withdraw the problem fallback with the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wildcard fallback looked only at the REQUEST, so it claimed every throwable Laravel's own handler resolves in the `match (true)` that runs AFTER renderViaCallbacks(): HttpResponseException, AuthenticationException and ValidationException. None of the three is a FireflyException or an HttpExceptionInterface, so ProblemMapper dropped each to its default arm and answered 500 / INTERNAL_ERROR / "An unexpected error occurred." — a 422 with its field errors, a 401, and a response the application had already built, all replaced by an opaque internal error and reported as one. ErrorPageRenderer::describes() is now the one term that looks at what was thrown, and the renderable asks it ahead of both branches: ahead of the problem document, and ahead of the PAGE, which read the Accept header alone and drew a diagnostic 500 over a browser's redirect-back-with-errors. The fallback is also gated on `enabled` now. prefersHtml() folds `json-paths` in, so an ungated term answered true for a browser on an `api/*` path with the page switched off — exactly the answer the same flag on forcesJson() was written to withhold, and the opposite of what the docblock and the reference comment shipped beside it claimed. One key cannot mean "do not draw the page" on one branch and "draw nothing at all" on the branch beside it. Pinned through the real Testbench pipeline, where a unit test of the predicate cannot see which exceptions Laravel would have resolved one arm later: Laravel validation keeps its 422 and its field errors for a JSON client and its redirect-back for a wildcard one, a 401 stays a 401, and a filter-thrown HttpResponseException still delivers the response it carries. The capstone test that named the replaced boolean is renamed to the rule the renderable applies and gains the wildcard and empty-Accept cases it never had. --- docs/modules/error-handling.md | 12 +++ packages/web/composer.json | 4 +- packages/web/src/Error/ErrorPageRenderer.php | 50 +++++++++++- packages/web/src/Error/ErrorPageSettings.php | 4 +- packages/web/src/WebServiceProvider.php | 10 +++ .../web/tests/CapstoneWebIntegrationTest.php | 81 +++++++++++++++++-- packages/web/tests/Error/ErrorPageTest.php | 51 ++++++++++++ .../web/tests/Fixtures/BoomController.php | 62 +++++++++++++- .../Filters/CarriedResponseFilter.php | 46 +++++++++++ skeleton/config/firefly.php | 8 +- 10 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 packages/web/tests/Fixtures/Filters/CarriedResponseFilter.php diff --git a/docs/modules/error-handling.md b/docs/modules/error-handling.md index b3e8e3e2..5acd6940 100644 --- a/docs/modules/error-handling.md +++ b/docs/modules/error-handling.md @@ -251,6 +251,18 @@ against an API into an HTML page — a worse regression than the bug being fixed path says what the URL *is*. It defaults to `api/*`, because a developer opening an API URL in a browser wants the payload their client will receive, not a styled page telling them the endpoint renders HTML. +The wildcard row is `firefly.web.error-page.problem-fallback` (default `true`); set it to `false` to let a +caller that named nothing fall through to Laravel's handler instead. Like `json-paths`, it is withdrawn +along with the page when `firefly.web.error-page.enabled` is `false` — that key means "use Laravel's own +error page", so it stops LaraFly adding answers rather than changing which answer is given. + +**Three exceptions are Laravel's own and LaraFly never answers them**, whatever the table above says: +`ValidationException`, `AuthenticationException` and `HttpResponseException`. Laravel's handler resolves +each of them itself immediately after it has consulted LaraFly's renderer, and none of the three is a +`FireflyException` or carries an HTTP status of its own — so describing them would replace a `422` with its +field errors, a `401`, or a response the application had already built, with an opaque `500`. A failed +`$request->validate()` in a LaraFly application behaves exactly as it does in a plain Laravel one. + ## The HTML error page `firefly/web` ships a page in the same visual language as the welcome page and the admin dashboard, showing diff --git a/packages/web/composer.json b/packages/web/composer.json index f962bf59..2686382e 100644 --- a/packages/web/composer.json +++ b/packages/web/composer.json @@ -28,11 +28,13 @@ "firefly/context": "*@dev", "firefly/kernel": "*@dev", "firefly/validation": "*@dev", + "illuminate/auth": "^13.0", "illuminate/contracts": "^13.0", "illuminate/http": "^13.0", "illuminate/log": "^13.0", "illuminate/routing": "^13.0", - "illuminate/support": "^13.0" + "illuminate/support": "^13.0", + "illuminate/validation": "^13.0" }, "extra": { "laravel": { diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php index 2052a560..709b1cab 100644 --- a/packages/web/src/Error/ErrorPageRenderer.php +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -7,9 +7,12 @@ use DateTimeImmutable; use DateTimeInterface; use Firefly\Kernel\Exception\FireflyException; +use Illuminate\Auth\AuthenticationException; use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Validation\ValidationException; use Throwable; /** @@ -41,6 +44,37 @@ public function __construct( private readonly ?ViewFactory $views = null, ) {} + /** + * Whether this package may answer for this throwable AT ALL — asked before either negotiation below, + * because both of them look at the REQUEST and neither looks at what was thrown. + * + * THREE THROWABLES BELONG TO LARAVEL'S OWN HANDLER, AND CLAIMING THEM DESTROYS THE FAILURE. + * Handler::render() consults renderViaCallbacks() — where this package's renderable lives — BEFORE its + * own `match (true)`, whose arms resolve `HttpResponseException` (which literally CARRIES the response + * to return), `AuthenticationException` (401, or the guest redirect to the login page) and + * `ValidationException` (422 with the field errors, or a redirect back with them in the session). Not + * one of the three is a FireflyException, and not one implements HttpExceptionInterface, so + * ProblemMapper::toFireflyException() drops every one of them to its default arm and answers + * 500 / `INTERNAL_ERROR` / "An unexpected error occurred." — an opaque internal error in place of a + * precisely described one, reported as a 500 into the bargain. A form POST that failed validation came + * back as a 500 with no `errors` member in it; a 401 came back as a 500. + * + * That is exactly the silent failure this whole surface exists to remove, so the rule is the plain one: + * a throwable Laravel resolves for itself is not this package's to describe, and the renderable returns + * null for it — for the PAGE as well as for the problem document, because `handles()` reads the Accept + * header alone and would otherwise draw a diagnostic 500 page over a browser's redirect-back-with-errors. + * + * The list is named rather than derived because there is nothing to derive it from: the arms are a + * literal `match` in Laravel's handler, and a test in ErrorPageTest asserts this predicate against each + * of the three so a Laravel release that adds a fourth is a failing test and not a silent 500. + */ + public function describes(Throwable $e): bool + { + return ! $e instanceof ValidationException + && ! $e instanceof AuthenticationException + && ! $e instanceof HttpResponseException; + } + /** Whether this request should be answered with the HTML page rather than with problem+json. */ public function handles(Request $request): bool { @@ -114,9 +148,17 @@ public function forcesJson(Request $request): bool * are answered exactly as they were. Only the caller who expressed no preference changes, and only * because "no preference" plus "not a browser" leaves one shape that anything can read. * - * A BROWSER IS NEVER CAUGHT BY IT, INCLUDING WHEN THE PAGE IS OFF. `prefersHtml()` — not `handles()` — - * is the test, so `firefly.web.error-page.enabled => false`, whose documented meaning is "use Laravel's - * own error page", keeps meaning that instead of silently turning every browser 404 into JSON. + * IT IS ALSO GATED ON THE FLAG, FOR `forcesJson()`'S REASON. The fallback asks `prefersHtml()`, and + * `prefersHtml()` folds `json-paths` in — so without `enabled` on the term, an `api/*` URL hit by a + * BROWSER would be claimed here with the page switched off, which is precisely the answer the same flag + * on `forcesJson()` was written to withhold (`forces nothing at all when the page is switched off`). + * One key would have meant two things: "do not draw the page" for one branch and "draw nothing at all" + * for the branch beside it. `firefly.web.error-page.enabled => false` keeps its documented meaning — + * this package stops adding answers and Laravel's own handler is left to it — while a FireflyException + * and a JSON client, which were never gated on the flag, are still answered exactly as before. + * + * AND A THROWABLE LARAVEL RESOLVES ITSELF NEVER REACHES HERE: see describes(), which the renderable + * asks first, and which is the only part of this negotiation that looks at what was thrown. */ public function rendersProblem(Throwable $e, Request $request): bool { @@ -124,7 +166,7 @@ public function rendersProblem(Throwable $e, Request $request): bool return true; } - return $this->settings->problemFallback && ! $this->prefersHtml($request); + return $this->settings->enabled && $this->settings->problemFallback && ! $this->prefersHtml($request); } public function render(Throwable $e, Request $request): Response diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 7512fe53..28928c86 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -28,7 +28,9 @@ * are different questions and only the first is about the URL. With the fallback on — the default, and what * the documentation has always claimed — a request carrying a WILDCARD Accept header, or no Accept at all, * is answered with the problem document, because that is the form a client can read and the page is for a - * person who asked for one. Off, such a request falls through to Laravel's handler exactly as it used to. + * person who asked for one. Off, such a request falls through to Laravel's handler exactly as it used to, + * and so it does whenever `enabled` is off: the fallback is a second answer this package offers, and + * `enabled => false` withdraws the answers rather than changing which one is given. * * `trace` DEFAULTS TO `app.debug` and is enforced at render time, not merely at template time — the renderer * builds no frame list, opens no source file and copies no exception message when it is off. That is diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 19f6247f..349abe94 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -232,6 +232,12 @@ private function registerBindings(): void * ErrorPageRenderer::rendersProblem(), beside handles() and prefersHtml(), because it is the same * negotiation asked a third way; this provider keeps only the wiring. * `firefly.web.error-page.problem-fallback => false` restores the fall-through. + * + * BOTH OF THOSE READ THE REQUEST, SO WHAT WAS THROWN IS ASKED FIRST. describes() is the one term that + * looks at the throwable, and it is asked ahead of both branches because the three exceptions Laravel's + * own `match (true)` resolves AFTER this callback runs — HttpResponseException, AuthenticationException, + * ValidationException — are not ours to answer in either shape. Returning null for them is what lets a + * 422 stay a 422 with its field errors, and a 401 stay a 401. */ private function registerProblemDetailsRenderable(): void { @@ -243,6 +249,10 @@ private function registerProblemDetailsRenderable(): void $handler->renderable(function (Throwable $e, Request $request) { $page = $this->app->make(ErrorPageRenderer::class); + if (! $page->describes($e)) { + return null; + } + if ($page->handles($request)) { return $page->render($e, $request); } diff --git a/packages/web/tests/CapstoneWebIntegrationTest.php b/packages/web/tests/CapstoneWebIntegrationTest.php index a85f249d..3b847e9c 100644 --- a/packages/web/tests/CapstoneWebIntegrationTest.php +++ b/packages/web/tests/CapstoneWebIntegrationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Web\Error\ProblemMapper; +use Firefly\Web\Tests\Fixtures\Filters\CarriedResponseFilter; use Firefly\Web\Tests\Support\WebCapstoneTestCase; use Illuminate\Support\Facades\Log; use Psr\Log\AbstractLogger; @@ -81,18 +82,33 @@ ->assertHeader('X-Filter-Trail', 'BA'); }); -it('renders a generic Throwable as problem+json ONLY when the request expects JSON (expectsJson gate)', function () { +it('renders a generic Throwable as a page ONLY for a caller that NAMED text/html, and as problem+json otherwise', function () { /** @var WebCapstoneTestCase $this */ - // GET /boom/generic throws a plain \RuntimeException (NOT a FireflyException). The RFC-7807 renderable is - // gated on `$e instanceof FireflyException || $request->expectsJson()`, so a generic error becomes - // problem+json for a JSON client and otherwise falls through to Laravel's default handler. + // GET /boom/generic throws a plain \RuntimeException (NOT a FireflyException). The renderable asks + // ErrorPageRenderer::handles() first — the page is for a caller that NAMED text/html — and + // rendersProblem() second, which claims a JSON client, a json-paths URL and, through + // `firefly.web.error-page.problem-fallback`, a caller that named nothing acceptable at all. $this->getJson('/boom/generic') ->assertStatus(500) ->assertHeader('Content-Type', 'application/problem+json'); - // Same route, non-JSON Accept: the generic Throwable must NOT be rendered as problem+json. Dropping the - // expectsJson() gate (rendering generic Throwables unconditionally) would make this branch wrongly return - // problem+json and fail the assertion below. + // A WILDCARD is not an opinion. `Accept: */*` is what a bare curl and a default fetch() send, and + // `acceptsHtml()` answers true for it — so keying the page off that would hand every unadorned + // command-line request a page of markup. It gets the document instead. + $this->call('GET', '/boom/generic', server: ['HTTP_ACCEPT' => '*/*']) + ->assertStatus(500) + ->assertHeader('Content-Type', 'application/problem+json'); + + // And so does a caller that sent no Accept at all. '' is as close as this harness gets: Symfony's + // Request::create() — which every call() here goes through — REPLACES an absent HTTP_ACCEPT with a + // browser's, and the predicate reads `headers->get('Accept', '')`, so an empty header and an absent + // one are the same string by the time it is asked. ErrorPageTest covers the genuinely absent one. + $this->call('GET', '/boom/generic', server: ['HTTP_ACCEPT' => '']) + ->assertStatus(500) + ->assertHeader('Content-Type', 'application/problem+json'); + + // Same route, a caller that NAMED text/html: this one, and only this one, gets the page. Dropping the + // handles() branch (rendering every generic Throwable as a document) would fail the assertion below. $html = $this->get('/boom/generic', ['Accept' => 'text/html']); /** @var Response $base */ @@ -100,6 +116,57 @@ expect((string) $base->headers->get('Content-Type'))->not->toContain('application/problem+json'); }); +/* + * AND THREE THROWABLES ARE NOT THIS PACKAGE'S TO ANSWER IN EITHER SHAPE. Handler::render() consults + * renderViaCallbacks() — where the renderable above is registered — BEFORE the `match (true)` that resolves + * HttpResponseException, AuthenticationException and ValidationException. None of the three is a + * FireflyException or an HttpExceptionInterface, so ProblemMapper drops each to its default arm and answers + * 500 / INTERNAL_ERROR / "An unexpected error occurred." — a described failure replaced by an opaque one, + * which is the shape this wave exists to remove. Only the real pipeline can falsify it: a unit test of the + * predicate cannot see which exceptions Laravel's own handler would have resolved one arm later. + */ +it('leaves a Laravel ValidationException to Laravel, so a 422 keeps its status and its field errors', function () { + /** @var WebCapstoneTestCase $this */ + // POST /boom/validated runs Laravel's own validator, not LaraFly's #[Valid] — an ordinary thing for an + // application built on this framework to do. + $this->postJson('/boom/validated', ['email' => 'nope']) + ->assertStatus(422) + ->assertJsonPath('errors.email.0', 'The email field must be a valid email address.'); + + // The wildcard caller is the one the fallback introduced and the one it broke: claiming it turned + // Laravel's redirect-back-with-errors into a 500 problem+json with no `errors` member in it. + $wildcard = $this->call('POST', '/boom/validated', ['email' => 'nope'], server: ['HTTP_ACCEPT' => '*/*']); + + $wildcard->assertStatus(302)->assertSessionHasErrors(['email']); + expect((string) $wildcard->headers->get('Content-Type'))->not->toContain('application/problem+json'); +}); + +it('leaves a Laravel AuthenticationException to Laravel, so a 401 stays a 401 and a person still reaches the login page', function () { + /** @var WebCapstoneTestCase $this */ + $this->getJson('/boom/unauthenticated') + ->assertStatus(401) + ->assertJsonPath('message', 'Unauthenticated.'); + + // Handler::unauthenticated() sends anyone who did not ask for JSON to the login page. Claiming the + // exception published a 500 to both, which is a sign-in prompt turned into an internal error. + $this->call('GET', '/boom/unauthenticated', server: ['HTTP_ACCEPT' => '*/*']) + ->assertStatus(302) + ->assertRedirect('/boom/login'); +}); + +it('leaves an HttpResponseException to Laravel, so the response the application already built survives', function () { + /** @var WebCapstoneTestCase $this */ + // The most literal form of the failure: this exception CARRIES the response to return, and Laravel's + // handler returns it verbatim one `match` arm after the renderable. Describing it instead threw that + // response away and answered 500. Thrown from the FILTER CHAIN rather than from a controller, because + // Illuminate\Routing\Route::run() catches it inside the route and it would never reach the handler at + // all — see CarriedResponseFilter. + $response = $this->call('GET', '/'.CarriedResponseFilter::PATH, server: ['HTTP_ACCEPT' => '*/*']); + + $response->assertStatus(418); + expect((string) $response->getContent())->toBe(CarriedResponseFilter::BODY); +}); + it('answers a malformed #[PathVariable(pattern:)] segment with the entity\'s own 404 through the real pipeline', function () { /** @var WebCapstoneTestCase $this */ // RoomsController::show declares PathVariable::UUID with ROOM_NOT_FOUND; the resolver refuses the diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index fbc8376a..56cdb4d8 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -13,9 +13,16 @@ use Firefly\Web\Error\ProblemMapper; use Firefly\Web\Exception\ProblemDetailsRenderer; use Firefly\Web\Trace\TraceContext; +use Illuminate\Auth\AuthenticationException as LaravelAuthenticationException; use Illuminate\Config\Repository; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; +use Illuminate\Translation\ArrayLoader; +use Illuminate\Translation\Translator; +use Illuminate\Validation\ValidationException; +use Illuminate\Validation\Validator; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -1348,3 +1355,47 @@ expect($off->handles($browser))->toBeFalse() ->and($off->rendersProblem(new NotFoundHttpException, $browser))->toBeFalse(); }); + +it('does not re-introduce an api/* path through the fallback that forcesJson() withholds when the page is off', function () { + // THE HOLE THE FLAG GATE CLOSES. `prefersHtml()` folds `json-paths` in — deliberately, because an api/* + // URL is a machine surface whatever header arrived — so an UNGATED fallback answered true for a BROWSER + // on an api/* path with the page switched off, which is exactly the answer the `enabled` gate on + // forcesJson() withholds two lines above (`forces nothing at all when the page is switched off`). One + // key cannot mean "do not draw the page" on one branch and "draw nothing at all" on the branch beside it. + $off = new ErrorPageRenderer(new ErrorPageSettings(enabled: false, jsonPaths: ['api/*'])); + $browser = Request::create('/api/nope', 'GET', server: ['HTTP_ACCEPT' => 'text/html,application/xhtml+xml']); + + expect($off->forcesJson($browser))->toBeFalse() + ->and($off->rendersProblem(new NotFoundHttpException, $browser))->toBeFalse() + // A wildcard caller on the same switched-off application falls through too: the fallback is one of + // the answers this package offers, and the flag withdraws them rather than choosing between them. + ->and($off->rendersProblem(new NotFoundHttpException, Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => '*/*'])))->toBeFalse() + // What was never gated on the flag stays exactly as it was: a FireflyException and a JSON client + // are still answered with a problem document. + ->and($off->rendersProblem(new ResourceNotFoundException('x', 'X'), $browser))->toBeTrue() + ->and($off->rendersProblem(new NotFoundHttpException, Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => 'application/json'])))->toBeTrue(); +}); + +it('describes nothing that Laravel\'s own handler resolves after the renderable callbacks run', function () { + // THE TERM THAT LOOKS AT WHAT WAS THROWN. handles() and rendersProblem() both read the REQUEST, so + // neither can tell a failure this package describes from one Laravel is about to resolve itself. + // Handler::render() consults renderViaCallbacks() BEFORE its `match (true)`, and none of the three arms + // below is a FireflyException or an HttpExceptionInterface — so ProblemMapper drops every one of them + // to its default arm and turns a 422 with field errors, a 401, and a response the application had + // already BUILT into 500 / INTERNAL_ERROR / "An unexpected error occurred.". + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: true)); + // Built rather than raised through the facade, because this file boots no application: the predicate + // asks only what the throwable IS, and the capstone next door proves the pipeline consequence. + $failed = new Validator(new Translator(new ArrayLoader, 'en'), ['email' => 'nope'], ['email' => ['email']]); + + expect($renderer->describes(new ValidationException($failed)))->toBeFalse() + ->and($renderer->describes(new LaravelAuthenticationException))->toBeFalse() + ->and($renderer->describes(new HttpResponseException(new SymfonyResponse('already built', 418))))->toBeFalse() + // And everything this package CAN describe is still its own: the taxonomy, the router's misses and + // the accidents, which is the whole population the two negotiations above are asked about. + ->and($renderer->describes(new ResourceNotFoundException('x', 'X')))->toBeTrue() + ->and($renderer->describes(new AuthenticationException('nope')))->toBeTrue() + ->and($renderer->describes(new NotFoundHttpException))->toBeTrue() + ->and($renderer->describes(new MethodNotAllowedHttpException(['GET'])))->toBeTrue() + ->and($renderer->describes(new RuntimeException('kaboom')))->toBeTrue(); +}); diff --git a/packages/web/tests/Fixtures/BoomController.php b/packages/web/tests/Fixtures/BoomController.php index d3536677..e83f19a3 100644 --- a/packages/web/tests/Fixtures/BoomController.php +++ b/packages/web/tests/Fixtures/BoomController.php @@ -5,18 +5,25 @@ namespace Firefly\Web\Tests\Fixtures; use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\PostMapping; use Firefly\Web\Attributes\RequestMapping; use Firefly\Web\Attributes\RestController; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Contracts\Validation\Factory as ValidationFactory; +use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException; use RuntimeException; /** - * Throws a GENERIC (non-FireflyException) Throwable so the capstone can prove the RFC-7807 renderable's - * expectsJson() gate: a generic error renders as problem+json ONLY when the request wants JSON; otherwise it - * falls through to Laravel's default handler. Its own base path (/boom) keeps it clear of /balances/{id}. + * Throws a GENERIC (non-FireflyException) Throwable so the capstone can prove which shape the RFC-7807 + * renderable answers in: a browser that NAMES text/html gets the HTML page, and a caller that named JSON, a + * wildcard or nothing at all gets problem+json. Its own base path (/boom) keeps it clear of /balances/{id}. * * It also throws the one exception Laravel's OWN handler rewrites on the way to a renderable, so the - * capstone can prove what reaches the wire after Handler::prepareException() has had it — see enumCase(). + * capstone can prove what reaches the wire after Handler::prepareException() has had it — see enumCase() — + * and the three Laravel's handler resolves for ITSELF after the renderable has been consulted, which this + * package must therefore decline: see validated() and unauthenticated(), and CarriedResponseFilter + * for the third, which a controller cannot raise as far as the exception handler. */ #[RestController] #[RequestMapping('/boom')] @@ -46,4 +53,51 @@ public function enumCase(): array { throw new BackedEnumCaseNotFoundException('App\Enums\Status', 'pending'); } + + /** + * LARAVEL'S OWN VALIDATION, not LaraFly's #[Valid] — and the difference is the whole point. + * + * A #[Valid] body raises a FireflyException that this package describes precisely. `$validator->validate()` + * raises Illuminate\Validation\ValidationException, which is neither a FireflyException nor an + * HttpExceptionInterface, so ProblemMapper drops it to its default arm: claiming it published a 500 + * INTERNAL_ERROR with no `errors` member, in place of the 422 Laravel's own handler resolves one + * `match` arm later. An application built on this framework still uses Laravel validation — in a + * FormRequest, in a controller, in a Livewire component — so this is an ordinary route, not an exotic one. + * + * @return array + */ + #[PostMapping('/validated')] + public function validated(ValidationFactory $validator, Request $request): array + { + $validator->make($request->all(), ['email' => ['required', 'email']])->validate(); + + return ['ok' => true]; + } + + /** + * The 401 Laravel resolves itself, through Handler::unauthenticated() — a JSON client is answered + * `{"message": …}` at 401 and anyone else is sent to the login page. Claiming it published a 500 + * INTERNAL_ERROR to both. + * + * @return array + */ + #[GetMapping('/unauthenticated')] + public function unauthenticated(): array + { + throw new AuthenticationException('Unauthenticated.'); + } + + /** + * The route Handler::unauthenticated() sends a non-JSON caller to. It exists because `route('login')` + * is what that method calls, and an application without one turns its own 401 into a + * RouteNotFoundException — Laravel's behaviour, not this package's, but it would hide the redirect + * this fixture is here to show. + * + * @return array + */ + #[GetMapping('/login', name: 'login')] + public function login(): array + { + return ['login' => true]; + } } diff --git a/packages/web/tests/Fixtures/Filters/CarriedResponseFilter.php b/packages/web/tests/Fixtures/Filters/CarriedResponseFilter.php new file mode 100644 index 00000000..ee0d322d --- /dev/null +++ b/packages/web/tests/Fixtures/Filters/CarriedResponseFilter.php @@ -0,0 +1,46 @@ +path() === self::PATH) { + throw new HttpResponseException(new Response(self::BODY, 418)); + } + + return $next($request); + } +} diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 7357f193..ac349d6d 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1049,8 +1049,12 @@ // | falling through to Laravel's own error page. `Accept: */*` is what a bare curl and a // | default fetch() send, and an absent Accept is what a hand-rolled client sends; neither // | names text/html, so neither is a browser, and both used to receive framework HTML for any - // | non-FireflyException. A browser is never caught by this — it is tested by `prefersHtml()`, - // | so `enabled => false` keeps meaning "use Laravel's page". + // | non-FireflyException. A browser is never caught by this, and neither is anything while + // | `enabled` is off: the fallback is one of the answers this package offers, and + // | `enabled => false` withdraws them, so it keeps meaning "use Laravel's page". + // | + // | A throwable Laravel's own handler resolves is never answered here whatever this says: a + // | failed validation stays a 422 with its field errors and a 401 stays a 401. // | // | Default: true. // */ From dc662351aba0c8c1b319ba4210ef60321d872efe Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 20:43:54 -0700 Subject: [PATCH 52/78] fix(admin): hide the data browser's search box where nothing is searchable, and stop its clear links dropping the page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the commit that moved the data browser onto the shared table system, both of them silent. The
    that commit replaced guarded its search form with `@if ($schema->searchable() !== [])`; the `_panel-head` include that took its place branches on `@isset($query)` alone and this page always passes a query, so the form is now drawn for every resource. `DataSchema::searchable()` keeps only non-sensitive string columns, so a join table of `id`, `order_id`, `quantity` — or one whose only text column is masked — publishes none, and `DataQueryEngine::fetch()` short-circuits to `[[], 0]` the moment that list is empty (the in-PHP path's `matches()` returns false for every row). The operator was handed a control whose only possible answer is `0 total` and "Nothing matches" over a table that has rows, with nothing on the page saying the resource has no searchable column: it reads as data loss. `_panel-head` now takes an optional `searchable` flag, defaulting to true so the twenty other callers are untouched, and draws the form only when it is set — inside the `query` branch, so the formatted grand total the browser suite reads as `3 total` still comes from the same line. Passing a null `query` instead would have hidden the form by falling through to `@elseif (isset($filter))`, onto the `DataFilter` this view leaves in scope from its own `@foreach`, and the bare branch below it prints an unformatted count with no word after it. The three "clear the filters" links — the banner above the table, the filter bar's own button and the empty state's "clear them all" — were rebuilt from `$base`, which is the resource URL and nothing else, so they dropped the operator's chosen page size along with the condition: set Rows to 100, apply a filter, clear it, and the table snapped back to the configured 25 under a reader who had chosen otherwise. They cannot come from `$query->link()`, which re-emits the carried filters they exist to shed, so they are built from `ListingQuery::own()` — the listing's own position without the carry — minus `page`, because widening a result set invalidates the offset into it. The filter controls keep the search term, the way the filter bar already re-submits `q` as a hidden field so that applying a condition keeps the search; the empty state's link sheds it too, which is what makes it the way back for a resource that renders no search box to clear a hand-edited `?q=` from. The comment at the top of the view claimed every in-listing link went through `link()` while these three did not, and now says which link has to shed a parameter and why. Regressions on all three, over a new `admin_links` fixture — four integers, the shape of any pivot table, which is the resource every existing fixture's string column hid. --- .../resources/views/_panel-head.blade.php | 41 ++++++---- .../admin/resources/views/data-list.blade.php | 44 ++++++++-- .../admin/tests/Data/Fixtures/AdminLink.php | 31 +++++++ .../Data/Fixtures/AdminLinkRepository.php | 18 +++++ packages/admin/tests/DataBrowserPageTest.php | 80 +++++++++++++++++++ .../tests/Support/DataBrowserTestCase.php | 42 +++++++++- 6 files changed, 233 insertions(+), 23 deletions(-) create mode 100644 packages/admin/tests/Data/Fixtures/AdminLink.php create mode 100644 packages/admin/tests/Data/Fixtures/AdminLinkRepository.php diff --git a/packages/admin/resources/views/_panel-head.blade.php b/packages/admin/resources/views/_panel-head.blade.php index 991297b5..abf9863b 100644 --- a/packages/admin/resources/views/_panel-head.blade.php +++ b/packages/admin/resources/views/_panel-head.blade.php @@ -20,25 +20,38 @@ readings of it — `7 total` unnarrowed and `5 total` under `?q=orders` — and AdminTablePagerTest pins the one reading a single-page fixture cannot: five matches behind a page of two. - The wording is not free, though: data-list.blade.php still hand-rolls the same words in its own -
    , and that is what tests/Browser/AdminDataBrowserTest.php's `assertSee('3 total')` hits today — - nothing in that file touches this branch. Keeping the literal identical is what lets the data browser - move onto this partial without rewriting a browser assertion. + The wording is not free, though: data-list.blade.php used to hand-roll the same words in its own +
    , and that is what tests/Browser/AdminDataBrowserTest.php's `assertSee('3 total')` hits. + Keeping the literal identical is what let the data browser move onto this partial without rewriting a + browser assertion. + + A SERVER-NARROWED LISTING CAN STILL HAVE NOTHING TO SEARCH, and `searchable` is how a caller says so + WITHOUT losing the grand total. It defaults to true because every actuator listing searches the strings + it renders; the data browser is the one caller that has to answer honestly, because + `DataSchema::searchable()` keeps only non-sensitive string columns and a join table of ints — or one + whose only text column is masked — publishes none. `DataQueryEngine` short-circuits to `[[], 0]` the + moment that list is empty, so drawing the box anyway offers a control whose only possible answer is + `0 total` and "Nothing matches" over a table that has rows: the operator reads data loss. The count + stays in this branch either way, because the number is still the server's grand total — the `filter` + and bare branches below print a DIFFERENT thing (a live shown-count, an unformatted tally) and falling + through to one of them to hide a form would change what the page says about itself. --}}

    {{ $title }}

    @isset($query) - - @foreach ($query->hiddenFields() as $field) - - @endforeach - - - @if ($query->isFiltered())Clear@endif - + @if ($searchable ?? true) + + @foreach ($query->hiddenFields() as $field) + + @endforeach + + + @if ($query->isFiltered())Clear@endif + + @endif {{ number_format($count) }} total @elseif (isset($filter)) identifierColumn(); // $base names a RECORD, not a listing: `&id=` and `&new=1` leave the table rather than moving - // within it, so they carry the resource and nothing else. Every link that stays in the listing goes - // through $query->link(), which is why the four "do not forget to carry this" strings that used to - // live here are gone. + // within it, so they carry the resource and nothing else. Every link that MOVES within the listing + // goes through $query->link(), which is why the four "do not forget to carry this" strings that + // used to live here are gone. $base = $settings->url('data').'?resource='.urlencode($resource?->slug ?? ''); + + // THE TWO LINKS THAT MUST SHED A PARAMETER, WHICH IS WHY THEY CANNOT COME FROM link(). `link()` + // re-emits everything the query CARRIES, and the applied filters are exactly that — so a "clear" + // built from it would hand back the view it was meant to leave. They are built from $base instead, + // plus `own()`: the listing's own position, which is what a clear must KEEP. Dropping it is the bug + // this page had — set Rows to 100, apply a filter, clear it, and the table silently snapped back to + // the configured default under a reader who had chosen otherwise. `page` is dropped on purpose: + // widening a result set invalidates the offset into it, so both links return to the top. + // + // They differ in one parameter. The filter controls SHED THE FILTERS and nothing else — a search + // term survives clearing a condition, the same way the filter bar re-submits `q` as a hidden field + // so that applying one keeps the search. The empty state's "clear them all" answers a question the + // reader is asking about every narrowing at once, so it sheds the search as well — and that link is + // the only way back for a resource with no searchable column, where a hand-edited `?q=` renders no + // search box to clear it from. + $own = $query->own(); + unset($own['page']); + $withoutSearch = $own; + unset($withoutSearch['q']); + $clearFilters = $base.($own === [] ? '' : '&'.http_build_query($own)); + $clearAll = $base.($withoutSearch === [] ? '' : '&'.http_build_query($withoutSearch)); @endphp
    @@ -49,7 +70,7 @@ @foreach ($listing->filters as $filter) {{ $filter->describe() }}@if (! $loop->last) and @endif @endforeach - · clear + · clear

    @endif @@ -119,7 +140,7 @@ @if ($listing->filters !== []) - Clear + Clear @endif Conditions are combined with and.
    @@ -131,9 +152,18 @@ resource, the ordering, the size and the filters through hiddenFields(), so searching inside a filtered listing NARROWS it instead of silently widening to the whole table; and the count is the formatted grand total followed by the word `total`, the wording this page - hand-rolled and tests/Browser/AdminDataBrowserTest.php reads as `3 total` / `1 total`. --}} + hand-rolled and tests/Browser/AdminDataBrowserTest.php reads as `3 total` / `1 total`. + + `searchable` IS THE ONE THING THIS PAGE HAS TO TELL THE PARTIAL. Every other listing on the + dashboard searches the strings it renders; a resource does not necessarily have any. + `DataSchema::searchable()` keeps only non-sensitive string columns, so a join table of + `id`, `order_id`, `quantity` — or one whose only text column is masked — publishes none, + and `DataQueryEngine` answers `[[], 0]` for every term the moment that list is empty. The + box is therefore drawn only where it can match, which is the guard the
    this + include replaced had and the reason the count is passed through the same branch. --}} @include('firefly-admin::_panel-head', [ 'title' => 'Records', 'count' => $listing->total, 'query' => $query, + 'searchable' => $schema->searchable() !== [], 'placeholder' => 'Search records…', ]) @@ -141,7 +171,7 @@ @include('firefly-admin::_empty', [ 'title' => $listing->search !== null || $listing->filters !== [] ? 'Nothing matches' : 'No records yet', 'body' => $listing->search !== null || $listing->filters !== [] - ? 'Loosen a condition, or clear them all.' + ? 'Loosen a condition, or clear them all.' : 'This resource has no rows.', ]) @else diff --git a/packages/admin/tests/Data/Fixtures/AdminLink.php b/packages/admin/tests/Data/Fixtures/AdminLink.php new file mode 100644 index 00000000..77ed9a8b --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminLink.php @@ -0,0 +1,31 @@ + + */ +final class AdminLinkRepository extends EloquentRepository +{ + protected string $model = AdminLink::class; +} diff --git a/packages/admin/tests/DataBrowserPageTest.php b/packages/admin/tests/DataBrowserPageTest.php index 658c8d57..b84d3cb7 100644 --- a/packages/admin/tests/DataBrowserPageTest.php +++ b/packages/admin/tests/DataBrowserPageTest.php @@ -207,3 +207,83 @@ ->toContain('') ->toContain(''); }); + +/** + * A SEARCH BOX THAT CAN ONLY EVER ANSWER "NOTHING MATCHES" IS WORSE THAN NO SEARCH BOX. + * + * `DataSchema::searchable()` keeps only non-sensitive string columns, so a join table of integers — or one + * whose only text column is masked — publishes none, and `DataQueryEngine::fetch()` short-circuits to + * `[[], 0]` the moment that list is empty. Drawing the control anyway hands the operator a way to empty a + * table that has rows, with `0 total` and "Nothing matches" and nothing on the page saying the resource has + * no searchable column: it reads as data loss. The
    this page moved off guarded the form with + * exactly this condition, and every other fixture here has a string column, which is why the guard could go + * missing with the suite still green. + */ +it('draws no search box on a resource with no searchable column, and still says how many rows there are', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminLinks(); + + $html = (string) $this->get('/firefly/data?resource=admin-link')->assertStatus(200)->getContent(); + + expect($html) + // The panel is the shared one and the grand total survives the missing form. + ->toContain('

    Records

    ') + ->toContain('2 total') + // No search control at all: not the input, not the submit, not the role that announces the form. + ->not->toContain('role="search"') + ->not->toContain('Search records…') + ->not->toContain('type="search"') + // And the rows really are there, which is what makes an empty answer a lie rather than a fact. + ->toContain('1–2 of 2'); +}); + +/** + * The same page with a term already in the URL — a hand-edited link or a stale bookmark. The listing comes + * back empty because the engine has no column to look in, and with no search form there is no "Clear" + * inside it, so the empty state's own link is the only way back. It must therefore shed `q`. + */ +it('leaves a way back when a resource with nothing to search is asked for a term', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminLinks(); + + $html = (string) $this->get('/firefly/data?resource=admin-link&size=100&q=anything')->assertStatus(200)->getContent(); + + // The anchor TEXT is in the assertion on purpose: the panel header's own "Clear" writes the same href, + // and it is not drawn here — so matching the href alone would pass against a page that offers no way + // out at all. + expect($html)->toContain('Nothing matches') + ->toContain('clear them all') + ->not->toContain('q=anything'); +}); + +/** + * CLEARING A CONDITION MUST NOT RESIZE THE TABLE UNDER THE READER. The three "clear" links leave the + * filters behind, which is precisely why they cannot come from `$query->link()` — that re-emits the + * carried filters — and building them from the bare resource URL instead dropped the page size with them: + * set Rows to 100, apply a filter, clear it, and the listing snapped back to the configured 25. They are + * built from `ListingQuery::own()` now, which is the listing's own position without the carry, minus + * `page`, because widening a result set invalidates the offset into it. + */ +it('keeps the chosen page size and ordering on the links that clear a filter', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminRecords(); + DB::table('admin_records')->insert([ + ['id' => 2, 'email' => 'grace@example.test', 'amount' => 150, 'active' => 1, 'created_at' => null], + ['id' => 3, 'email' => 'linus@example.test', 'amount' => 250, 'active' => 0, 'created_at' => null], + ]); + + $html = (string) $this->get('/firefly/data?resource=admin-record&size=100&sort=amount&dir=desc&q=example&fk=active&fv=1') + ->assertStatus(200)->getContent(); + + $clear = '/firefly/data?resource=admin-record&q=example&sort=amount&dir=desc&size=100'; + + expect($html) + // The banner above the table and the filter bar's own button, both still at 100 rows. + ->toContain('clear') + ->toContain('Clear') + // Neither one carries the condition it exists to remove. + ->and(substr_count($html, $clear.'&fk='))->toBe(0); + + // And the size really is the one that was asked for, not the browser's default. + expect($html)->toContain(''); +}); diff --git a/packages/admin/tests/Support/DataBrowserTestCase.php b/packages/admin/tests/Support/DataBrowserTestCase.php index 43fb013c..f552b864 100644 --- a/packages/admin/tests/Support/DataBrowserTestCase.php +++ b/packages/admin/tests/Support/DataBrowserTestCase.php @@ -5,6 +5,7 @@ namespace Firefly\Admin\Tests\Support; use Firefly\Actuator\Introspection\BeansCatalog; +use Firefly\Admin\Tests\Data\Fixtures\AdminLinkRepository; use Firefly\Admin\Tests\Data\Fixtures\AdminRecordRepository; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Application; @@ -66,12 +67,49 @@ public function exposeAdminRecords(): void 'amount' => 50, 'active' => 1, 'meta' => null, 'created_at' => '2026-01-01 10:00:00', ]); + $this->exposeRepository(AdminRecordRepository::class); + } + + /** + * The same, for a resource with NOTHING TO SEARCH: `admin_links` is four integers, the shape of any + * pivot table, so `DataSchema::searchable()` publishes no column and `DataQueryEngine` answers `[[], 0]` + * to every term. It replaces the catalogue rather than adding to it, exactly as its sibling does — a + * test wants one resource in the menu, not two. + * + * Public for the same reason the sibling is: Pest binds the closure's `$this` to a class PHPStan cannot + * relate to this one. + */ + public function exposeAdminLinks(): void + { + Schema::create('admin_links', function (Blueprint $table): void { + $table->increments('id'); + $table->integer('record_id'); + $table->integer('entry_id'); + $table->integer('quantity'); + }); + + DB::table('admin_links')->insert([ + ['id' => 1, 'record_id' => 1, 'entry_id' => 7, 'quantity' => 3], + ['id' => 2, 'record_id' => 1, 'entry_id' => 8, 'quantity' => 5], + ]); + + $this->exposeRepository(AdminLinkRepository::class); + } + + /** + * The actuator's boot-time bean snapshot, swapped for one naming a single repository — done BEFORE the + * first request, which is when the dashboard assembles its DataBrowser and reads the catalogue. + * + * @param class-string $repository + */ + protected function exposeRepository(string $repository): void + { $this->app()->instance(BeansCatalog::class, new BeansCatalog([[ - 'class' => AdminRecordRepository::class, + 'class' => $repository, 'stereotype' => 'repository', 'scope' => 'Singleton', 'name' => null, - 'interfaces' => array_values(class_implements(AdminRecordRepository::class) ?: []), + 'interfaces' => array_values(class_implements($repository) ?: []), 'beans' => [], ]])); } From 01529410ce8f0cce7cbec68108acfcf3d882aa66 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 21:14:03 -0700 Subject: [PATCH 53/78] test(web): derive the throwables describes() stands aside for from the installed Laravel instead of a list that re-asserts itself --- packages/web/src/Error/ErrorPageRenderer.php | 12 +++- .../tests/Error/LaravelHandlerArmsTest.php | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 packages/web/tests/Error/LaravelHandlerArmsTest.php diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php index 709b1cab..c919212a 100644 --- a/packages/web/src/Error/ErrorPageRenderer.php +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -64,9 +64,15 @@ public function __construct( * null for it — for the PAGE as well as for the problem document, because `handles()` reads the Accept * header alone and would otherwise draw a diagnostic 500 page over a browser's redirect-back-with-errors. * - * The list is named rather than derived because there is nothing to derive it from: the arms are a - * literal `match` in Laravel's handler, and a test in ErrorPageTest asserts this predicate against each - * of the three so a Laravel release that adds a fourth is a failing test and not a silent 500. + * THE LIST IS NAMED, AND THE TRIPWIRE UNDER IT IS NOT THIS PREDICATE'S OWN TEST. ErrorPageTest asserts + * this method against each of the three, which pins what THIS method does and would go on passing for + * ever if Laravel grew a fourth arm: the predicate knows nothing about the handler, so a test that + * constructs the three exceptions itself cannot notice a fourth. That test is therefore not the + * protection, and saying it was would have told a maintainer on a Laravel upgrade that the list + * re-checks itself. LaravelHandlerArmsTest is the protection: it reads `Handler::render()` out of the + * INSTALLED Laravel through reflection, extracts the classes its `match (true)` resolves, and fails + * unless that set is exactly these three — so a release that adds a fourth arm is a failing test naming + * the class to add here, and not a silent 500 in production. */ public function describes(Throwable $e): bool { diff --git a/packages/web/tests/Error/LaravelHandlerArmsTest.php b/packages/web/tests/Error/LaravelHandlerArmsTest.php new file mode 100644 index 00000000..b79e04c7 --- /dev/null +++ b/packages/web/tests/Error/LaravelHandlerArmsTest.php @@ -0,0 +1,68 @@ +getFileName(); + + expect($file)->toBeString(); + + $source = is_string($file) ? (string) file_get_contents($file) : ''; + $lines = explode("\n", $source); + $body = implode("\n", array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); + + // Everything from the `match (true) {` to the end of the method: the arms, and anything a future + // release adds after them. Taken from that offset rather than from the top of the method because + // render() tests `$e instanceof Responsable` several lines ABOVE the match, and that one is resolved + // BEFORE renderViaCallbacks() runs — a Responsable never reaches our renderable at all, so it is not + // one of the classes describes() has to name. + $match = strstr($body, 'match (true) {'); + + expect($match)->toBeString('Handler::render() no longer resolves anything with a `match (true)`, so the shape ErrorPageRenderer::describes() was written against has changed. Re-read the method.'); + + preg_match_all('/\$e instanceof ([A-Za-z_\\\\]+)\s*=>/', is_string($match) ? $match : '', $found); + + $arms = $found[1]; + sort($arms); + + // Sorted, so re-ordering the arms — which changes nothing about WHICH throwables Laravel claims — is + // not a red build. Adding or removing one is. + expect($arms)->toBe(['AuthenticationException', 'HttpResponseException', 'ValidationException']); + + // And the short names in that match are the classes describes() actually names: the arms are written + // unqualified, so without this the test would pass on an import swapped to a same-named class in + // another namespace. + foreach ([AuthenticationException::class, HttpResponseException::class, ValidationException::class] as $class) { + expect($source)->toContain('use '.$class.';'); + } +}); From bbf7b7454490ff273d042766eb8572bf91fd4634 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 21:14:06 -0700 Subject: [PATCH 54/78] docs(web): say that the problem fallback claims every caller that is neither a browser nor a JSON client, and pin it with a named type --- docs/modules/error-handling.md | 17 +++++++++---- packages/web/src/Error/ErrorPageRenderer.php | 20 +++++++++++++-- packages/web/src/Error/ErrorPageSettings.php | 24 +++++++++++------- packages/web/src/WebServiceProvider.php | 7 +++--- .../web/tests/CapstoneWebIntegrationTest.php | 8 ++++++ packages/web/tests/Error/ErrorPageTest.php | 25 +++++++++++++++++++ skeleton/config/firefly.php | 22 ++++++++++------ 7 files changed, 97 insertions(+), 26 deletions(-) diff --git a/docs/modules/error-handling.md b/docs/modules/error-handling.md index 5acd6940..489d492f 100644 --- a/docs/modules/error-handling.md +++ b/docs/modules/error-handling.md @@ -240,7 +240,7 @@ unreadable for people. |---|---| | Named `text/html` (or `application/xhtml+xml`) in `Accept` | The HTML error page | | Asked for JSON, or is an `XMLHttpRequest` | `application/problem+json` | -| Sent only a wildcard `Accept` — a bare `curl` | `application/problem+json` | +| Sent only a wildcard `Accept` — a bare `curl` — or no `Accept`, or named some other type (`application/xml`) | `application/problem+json` | | Requested a path under `firefly.web.error-page.json-paths` | `application/problem+json`, whatever it asked for | The rule is **the client NAMED text/html**, not `acceptsHtml()`. A bare `curl` sends `*/*`, which @@ -251,10 +251,17 @@ against an API into an HTML page — a worse regression than the bug being fixed path says what the URL *is*. It defaults to `api/*`, because a developer opening an API URL in a browser wants the payload their client will receive, not a styled page telling them the endpoint renders HTML. -The wildcard row is `firefly.web.error-page.problem-fallback` (default `true`); set it to `false` to let a -caller that named nothing fall through to Laravel's handler instead. Like `json-paths`, it is withdrawn -along with the page when `firefly.web.error-page.enabled` is `false` — that key means "use Laravel's own -error page", so it stops LaraFly adding answers rather than changing which answer is given. +That third row is `firefly.web.error-page.problem-fallback` (default `true`), and it covers every caller +that is **neither a browser nor a JSON client**: a wildcard `Accept`, an absent one, and a caller that named +a concrete type LaraFly renders no error in — `application/xml`, `text/plain`, `image/png`. The wider rule +is the consistent one. A `FireflyException` has always been answered with `application/problem+json` +whatever the `Accept` header said, so catching only the wildcard would hand one XML client a problem +document for a taxonomy 404 and Laravel's stock HTML page for a router 404. Errors have exactly two shapes +here: a `MessageConverter` you add for XML converts what a **controller returns**, and neither error +renderer is wired through it. Set the key to `false` to let all of those callers fall through to Laravel's +handler instead. Like `json-paths`, it is withdrawn along with the page when +`firefly.web.error-page.enabled` is `false` — that key means "use Laravel's own error page", so it stops +LaraFly adding answers rather than changing which answer is given. **Three exceptions are Laravel's own and LaraFly never answers them**, whatever the table above says: `ValidationException`, `AuthenticationException` and `HttpResponseException`. Laravel's handler resolves diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php index c919212a..e1274d5e 100644 --- a/packages/web/src/Error/ErrorPageRenderer.php +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -151,8 +151,24 @@ public function forcesJson(Request $request): bool * page shipped. * * THE FALLBACK IS THE LAST TERM, NOT THE FIRST. A FireflyException, a JSON client and a `json-paths` URL - * are answered exactly as they were. Only the caller who expressed no preference changes, and only - * because "no preference" plus "not a browser" leaves one shape that anything can read. + * are answered exactly as they were; this term only adds an answer where the package previously gave + * none. + * + * AND IT CLAIMS EVERY CALLER THAT IS NEITHER A BROWSER NOR A JSON CLIENT — not only the one that named + * nothing at all. `! prefersHtml()` is true of a wildcard Accept and of an absent one, the two cases + * this term was written for, and it is equally true of `Accept: application/xml`, `text/plain` or + * `image/png`: a caller that named a concrete type this package does not render an error in. That is + * deliberate, and it is the narrower rule that would be the inconsistency. A FireflyException has + * ALWAYS been answered with problem+json whatever the Accept header said — the first term above, + * unchanged since before this method existed — so a fallback restricted to a literal wildcard would + * hand one XML client a problem document for a taxonomy 404 and Laravel's stock HTML page for a router + * 404, which is one application answering one failure two unrelated-looking ways and is the exact shape + * the class comment above opens by describing. Nor is there a third shape to offer: the + * MessageConverterRegistry converts what a CONTROLLER returns and an application may well add XML to + * it, but neither error renderer is wired through it, and problem+json is the only machine-readable + * document this package writes. So the key is `problem-fallback` rather than a wildcard-shaped name, + * and every sentence that documents it says what it actually claims: a caller that did not name + * text/html and did not ask for JSON. * * IT IS ALSO GATED ON THE FLAG, FOR `forcesJson()`'S REASON. The fallback asks `prefersHtml()`, and * `prefersHtml()` folds `json-paths` in — so without `enabled` on the term, an `api/*` URL hit by a diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 28928c86..0115b2ec 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -24,13 +24,18 @@ * while a 500 in staging is still the framework's diagnostic one. * * `problem-paths` IS NOT A KEY AND `problem-fallback` IS. The question `json-paths` answers is "which URLs - * are machine surfaces"; the question this one answers is "what does a caller who named nothing get". They - * are different questions and only the first is about the URL. With the fallback on — the default, and what - * the documentation has always claimed — a request carrying a WILDCARD Accept header, or no Accept at all, - * is answered with the problem document, because that is the form a client can read and the page is for a - * person who asked for one. Off, such a request falls through to Laravel's handler exactly as it used to, - * and so it does whenever `enabled` is off: the fallback is a second answer this package offers, and - * `enabled => false` withdraws the answers rather than changing which one is given. + * are machine surfaces"; the question this one answers is "what does a caller that is not a browser and did + * not ask for JSON get". They are different questions and only the first is about the URL. With the + * fallback on — the default, and what the documentation has always claimed — such a request is answered + * with the problem document, because that is the form a client can read and the page is for a person who + * asked for one. That population is wider than the wildcard it was written for: a WILDCARD Accept header + * and an absent one are in it, and so is a caller that named a concrete type this package cannot render an + * error in (`application/xml`, `text/plain`), which gets the document rather than Laravel's markup for the + * reason ErrorPageRenderer::rendersProblem() sets out — a FireflyException has always answered that same + * caller with problem+json, and the narrower rule would be the inconsistency. Off, such a request falls + * through to Laravel's handler exactly as it used to, and so it does whenever `enabled` is off: the + * fallback is a second answer this package offers, and `enabled => false` withdraws the answers rather than + * changing which one is given. * * `trace` DEFAULTS TO `app.debug` and is enforced at render time, not merely at template time — the renderer * builds no frame list, opens no source file and copies no exception message when it is off. That is @@ -135,8 +140,9 @@ public function __construct( public bool $authoredDetail = true, // Progressive enhancement, and the only script this page has ever carried: see ErrorPage::clipboard(). public bool $copyButton = true, - // Whether a caller that named NOTHING acceptable gets a problem document rather than Laravel's own - // page. See ErrorPageRenderer::rendersProblem() for the case this closes. + // Whether a caller that is neither a browser nor a JSON client — a wildcard Accept header, no + // Accept at all, or a named type this package renders no error in — gets a problem document rather + // than Laravel's own page. See ErrorPageRenderer::rendersProblem() for the case this closes. public bool $problemFallback = true, ) { $this->home = self::url($home); diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 349abe94..52564a71 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -226,9 +226,10 @@ private function registerBindings(): void * FireflyException had. * * Everything else keeps the previous rule and gains the case it was missing: a FireflyException, a - * request that wants JSON and a `json-paths` URL all render as problem+json, and so now does a caller - * that named NOTHING — a wildcard Accept header from a bare curl, or no Accept at all — which used to - * fall through to Laravel's stock HTML page. The predicate itself lives in + * request that wants JSON and a `json-paths` URL all render as problem+json, and so now does every + * caller that is neither a browser nor a JSON client — a wildcard Accept header from a bare curl, no + * Accept at all, or a named type this package renders no error in, such as `application/xml` — each of + * which used to fall through to Laravel's stock HTML page. The predicate itself lives in * ErrorPageRenderer::rendersProblem(), beside handles() and prefersHtml(), because it is the same * negotiation asked a third way; this provider keeps only the wiring. * `firefly.web.error-page.problem-fallback => false` restores the fall-through. diff --git a/packages/web/tests/CapstoneWebIntegrationTest.php b/packages/web/tests/CapstoneWebIntegrationTest.php index 3b847e9c..9c560b0f 100644 --- a/packages/web/tests/CapstoneWebIntegrationTest.php +++ b/packages/web/tests/CapstoneWebIntegrationTest.php @@ -107,6 +107,14 @@ ->assertStatus(500) ->assertHeader('Content-Type', 'application/problem+json'); + // And so does a caller that named something concrete this application renders no error in. `Accept: + // application/xml` is not a browser and is not a JSON client, and the key claims it deliberately: a + // FireflyException has always answered this same caller with a problem document, so claiming only the + // wildcard would give one client two unrelated-looking shapes for two 404s. + $this->call('GET', '/boom/generic', server: ['HTTP_ACCEPT' => 'application/xml']) + ->assertStatus(500) + ->assertHeader('Content-Type', 'application/problem+json'); + // Same route, a caller that NAMED text/html: this one, and only this one, gets the page. Dropping the // handles() branch (rendering every generic Throwable as a document) would fail the assertion below. $html = $this->get('/boom/generic', ['Accept' => 'text/html']); diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 56cdb4d8..ac5653bd 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -1346,6 +1346,31 @@ ->and($off->rendersProblem(new NotFoundHttpException, Request::create('/api/nope', 'GET')))->toBeTrue(); }); +it('answers a caller that named a type it renders no error in, and stands down for it with the key off', function () { + // THE POPULATION THE KEY CLAIMS IS WIDER THAN THE WILDCARD IT WAS WRITTEN FOR, and this is the case + // that says so out loud rather than leaving it to be discovered in production. `Accept: application/xml` + // names something concrete and is still neither a browser (nothing in it is text/html) nor a JSON + // client (`wantsJson()` reads the FIRST acceptable type and that is not a JSON one), so the fallback + // claims it — and the last line of the loop is why that is the consistent rule rather than an + // overreach: this same XML client has ALWAYS been answered with a problem document for a + // FireflyException, so a fallback narrowed to a literal wildcard would hand it one shape for a taxonomy + // 404 and Laravel's stock markup for a router 404 on one application. + $on = new ErrorPageRenderer(new ErrorPageSettings(enabled: true)); + $off = new ErrorPageRenderer(new ErrorPageSettings(enabled: true, problemFallback: false)); + + foreach (['application/xml', 'text/plain', 'image/png'] as $accept) { + $named = Request::create('/orders/9', 'GET', server: ['HTTP_ACCEPT' => $accept]); + + expect($on->handles($named))->toBeFalse() + ->and($on->rendersProblem(new NotFoundHttpException, $named))->toBeTrue() + // With the key off this caller falls through to Laravel exactly as a wildcard one does, which + // is the escape hatch an application with its own opinion about an unrouted URL reaches for … + ->and($off->rendersProblem(new NotFoundHttpException, $named))->toBeFalse() + // … and what was never the fallback's to give away is unchanged in both positions. + ->and($off->rendersProblem(new ResourceNotFoundException('x', 'X'), $named))->toBeTrue(); + } +}); + it('claims nothing at all when the page is switched off and the caller is a browser', function () { // `enabled: false` means "use Laravel's stock error page", and it must keep meaning that: a browser // gets Laravel's page, and the fallback does not quietly turn it into JSON. diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index ac349d6d..46d4ef20 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1045,13 +1045,21 @@ // 'copy-button' => env('FIREFLY_WEB_ERROR_PAGE_COPY_BUTTON', true), // // /* - // | Whether a caller that named NOTHING acceptable gets `application/problem+json` rather than - // | falling through to Laravel's own error page. `Accept: */*` is what a bare curl and a - // | default fetch() send, and an absent Accept is what a hand-rolled client sends; neither - // | names text/html, so neither is a browser, and both used to receive framework HTML for any - // | non-FireflyException. A browser is never caught by this, and neither is anything while - // | `enabled` is off: the fallback is one of the answers this package offers, and - // | `enabled => false` withdraws them, so it keeps meaning "use Laravel's page". + // | Whether a caller that is NEITHER A BROWSER NOR A JSON CLIENT gets + // | `application/problem+json` rather than falling through to Laravel's own error page. + // | `Accept: */*` is what a bare curl and a default fetch() send, and an absent Accept is what + // | a hand-rolled client sends; neither names text/html, so neither is a browser, and both + // | used to receive framework HTML for any non-FireflyException. + // | + // | A CALLER THAT NAMED SOME OTHER TYPE IS CAUGHT TOO — `application/xml`, `text/plain`, + // | `image/png`. It asked for something this package renders no error in, and a + // | FireflyException has always answered that same caller with a problem document whatever it + // | asked for; claiming only the wildcard would leave one client reading a problem document + // | for one 404 and framework markup for another. + // | + // | A browser is never caught by this, and neither is anything while `enabled` is off: the + // | fallback is one of the answers this package offers, and `enabled => false` withdraws them, + // | so it keeps meaning "use Laravel's page". // | // | A throwable Laravel's own handler resolves is never answered here whatever this says: a // | failed validation stays a 422 with its field errors and a 401 stays a 401. From cd368fde4cd14d7082b9bfef06ea4ad3238c0eaa Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 21:36:43 -0700 Subject: [PATCH 55/78] fix(web): identify a problem occurrence with a root-relative instance and carry an RFC 9457 type member --- book/src-es/04-first-http-api.md | 9 ++- book/src/04-first-http-api.md | 9 ++- packages/web/src/Error/ErrorPageSettings.php | 4 ++ packages/web/src/Error/ProblemType.php | 57 +++++++++++++++++++ .../src/Exception/ProblemDetailsRenderer.php | 35 +++++++++++- packages/web/tests/Error/ProblemTypeTest.php | 44 ++++++++++++++ .../Exception/ProblemDetailsRendererTest.php | 56 +++++++++++++++++- skeleton/config/firefly.php | 23 ++++++++ 8 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 packages/web/src/Error/ProblemType.php create mode 100644 packages/web/tests/Error/ProblemTypeTest.php diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index 229bb3fa..6fb9e0c7 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -427,13 +427,16 @@ final class ProblemDetailsRenderer $exception = ProblemMapper::toFireflyException($e, $disclose, $reference); // … - $payload = ErrorResponse::fromException( + $problem = ErrorResponse::fromException( $exception, - instance: $request->path(), + // … + instance: ProblemMapper::instanceFor($request), traceId: $reference, timestamp: (new DateTimeImmutable)->format(DateTimeInterface::ATOM), correlationId: $correlationId, - )->toArray(); + ); + // … + $payload = $problem->toArray(); $headers = [ 'Content-Type' => 'application/problem+json', diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md index 4359e6ae..af0b367d 100644 --- a/book/src/04-first-http-api.md +++ b/book/src/04-first-http-api.md @@ -427,13 +427,16 @@ final class ProblemDetailsRenderer $exception = ProblemMapper::toFireflyException($e, $disclose, $reference); // … - $payload = ErrorResponse::fromException( + $problem = ErrorResponse::fromException( $exception, - instance: $request->path(), + // … + instance: ProblemMapper::instanceFor($request), traceId: $reference, timestamp: (new DateTimeImmutable)->format(DateTimeInterface::ATOM), correlationId: $correlationId, - )->toArray(); + ); + // … + $payload = $problem->toArray(); $headers = [ 'Content-Type' => 'application/problem+json', diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 0115b2ec..13f28ae1 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -144,6 +144,9 @@ public function __construct( // Accept at all, or a named type this package renders no error in — gets a problem document rather // than Laravel's own page. See ErrorPageRenderer::rendersProblem() for the case this closes. public bool $problemFallback = true, + // RFC 9457 §3.1.1's `type`: 'about:blank' (the default, and what Spring's ProblemDetail emits), '' + // to omit the member, or a BASE URI from which the stable error code derives one. See ProblemType. + public string $typeUri = ProblemType::BLANK, ) { $this->home = self::url($home); $this->signIn = self::url($signIn); @@ -209,6 +212,7 @@ public static function fromConfig(Config $config): self // Explicit, and only explicit: no fallback to app.debug, no fallback to `trace`. See the class // comment for the leak that a shared gate produced. disclose: $config->bool('firefly.web.problem.disclose', false), + typeUri: $config->string('firefly.web.problem.type-uri', ProblemType::BLANK), // Handed over RAW: the constructor runs each of these through url(), so this call site cannot // be the one that forgets. The default home is the site root, because a page with no way off it // is the state every one of these screenshots was in. diff --git a/packages/web/src/Error/ProblemType.php b/packages/web/src/Error/ProblemType.php new file mode 100644 index 00000000..47c36a15 --- /dev/null +++ b/packages/web/src/Error/ProblemType.php @@ -0,0 +1,57 @@ +path(), + // RFC 9457 §3.1.5: `instance` is a URI REFERENCE, and a relative one resolves against the + // document's base URI — so the bare `api/orders/42` this used to pass, served from + // /api/orders/42, identified /api/api/orders/42. The rule is ProblemMapper's because the HTML + // page beside this one builds the same reference, and two spellings of it would eventually + // disagree about the same request. + instance: ProblemMapper::instanceFor($request), traceId: $reference, timestamp: (new DateTimeImmutable)->format(DateTimeInterface::ATOM), correlationId: $correlationId, - )->toArray(); + ); + + // RFC 9457 §3.1.1, carried on the DTO rather than written onto toArray()'s output: `type` is a + // DECLARED member of ErrorResponse and of the published ProblemSchema, and the class comment at + // ErrorResponse.php:24 is explicit that a member appended downstream is one every generated client + // drops. Re-declaring the DTO with one field changed is the honest way to set it on a readonly + // value object, and it keeps toArray()'s member ORDER the single source of truth. + $problem = new ErrorResponse( + status: $problem->status, + title: $problem->title, + code: $problem->code, + category: $problem->category, + severity: $problem->severity, + detail: $problem->detail, + type: ProblemType::of($problem->code, $this->settings instanceof ErrorPageSettings ? $this->settings->typeUri : ProblemType::BLANK), + instance: $problem->instance, + traceId: $problem->traceId, + errors: $problem->errors, + timestamp: $problem->timestamp, + extensions: $problem->extensions, + correlationId: $problem->correlationId, + ); + + $payload = $problem->toArray(); $headers = [ 'Content-Type' => 'application/problem+json', diff --git a/packages/web/tests/Error/ProblemTypeTest.php b/packages/web/tests/Error/ProblemTypeTest.php new file mode 100644 index 00000000..58420d29 --- /dev/null +++ b/packages/web/tests/Error/ProblemTypeTest.php @@ -0,0 +1,44 @@ +toBe('about:blank') + ->and(ProblemType::of('INTERNAL_ERROR', 'about:blank'))->toBe('about:blank'); +}); + +it('omits the member entirely when the deployment asks for the pre-9457 document', function () { + expect(ProblemType::of('RESOURCE_NOT_FOUND', ''))->toBeNull(); +}); + +it('derives a real type URI from the stable error code when a base is configured', function () { + expect(ProblemType::of('RESOURCE_NOT_FOUND', 'https://api.example.test/problems')) + ->toBe('https://api.example.test/problems/resource-not-found') + // A trailing slash on the base is the obvious thing to write and must not produce a double one. + ->and(ProblemType::of('ORDER_ALREADY_SHIPPED', 'https://api.example.test/problems/')) + ->toBe('https://api.example.test/problems/order-already-shipped') + // The framework's own synthesised codes are URIs like any other. + ->and(ProblemType::of('HTTP_418', 'https://example.test/p'))->toBe('https://example.test/p/http-418'); +}); + +it('slugs a code the way a URL path wants it, and never emits an empty last segment', function () { + expect(ProblemType::slug('METHOD_NOT_ALLOWED'))->toBe('method-not-allowed') + ->and(ProblemType::slug('EDITION_LIMIT'))->toBe('edition-limit') + ->and(ProblemType::slug('X'))->toBe('x') + // A code that slugs to nothing would make the base URI itself the type of every such problem, which + // is worse than saying nothing: the base is the collection, not a member of it. + ->and(ProblemType::of('', 'https://api.example.test/problems'))->toBe('about:blank') + ->and(ProblemType::of('___', 'https://api.example.test/problems'))->toBe('about:blank'); +}); diff --git a/packages/web/tests/Exception/ProblemDetailsRendererTest.php b/packages/web/tests/Exception/ProblemDetailsRendererTest.php index 5632b3aa..9b6c60d4 100644 --- a/packages/web/tests/Exception/ProblemDetailsRendererTest.php +++ b/packages/web/tests/Exception/ProblemDetailsRendererTest.php @@ -38,7 +38,11 @@ ->and($payload['code'])->toBe('RESOURCE_NOT_FOUND') ->and($payload['category'])->toBe('business') ->and($payload['detail'])->toBe('Account 42 not found') - ->and($payload['instance'])->toBe('accounts/42'); + // RFC 9457 §3.1.5: `instance` is a URI reference, and a RELATIVE one resolves against the document's + // base URI — so `accounts/42` served from /accounts/42 identifies /accounts/accounts/42. One + // character, and the member stops identifying the occurrence it exists to identify. Spring's + // ProblemDetail sets it from the request URI for the same reason. + ->and($payload['instance'])->toBe('/accounts/42'); }); it('converts a generic Throwable to a 500 problem+json', function () { @@ -330,7 +334,10 @@ // And the degraded body is still correlatable, which is the one action it exists to make possible. ->and($payload['traceId'])->toBe('corr-77') ->and($payload['correlationId'])->toBe('corr-77') - ->and($payload['instance'])->toBe('api/x') + // Root-relative here too, for RFC 9457 §3.1.5's reason — see the first test in this file. The + // degraded document carries the SAME member the healthy one would have carried, which is the whole + // promise minimal() makes: it diffs against the full document member for member. + ->and($payload['instance'])->toBe('/api/x') ->and($payload['timestamp'])->toBeString() // THE MEMBER STAYS AND SAYS WHAT IT IS. Deleting it published a document a healthy one could not // be told apart from, and took every OTHER extension member down with it — including `allowed`, @@ -581,3 +588,48 @@ public function jsonSerialize(): mixed ->and($context)->toBeArray() ->and(is_array($context) ? $context['tenant'] : null)->toBe('acme'); }); + +it('identifies the occurrence with a root-relative reference, at every depth and at the site root', function () { + $renderer = new ProblemDetailsRenderer; + + $deep = json_decode((string) $renderer->render(new ResourceNotFoundException('x'), Request::create('/api/v1/orders/42/lines/7'))->getContent(), true); + $root = json_decode((string) $renderer->render(new ResourceNotFoundException('x'), Request::create('/'))->getContent(), true); + + /** @var array $deep */ + /** @var array $root */ + expect($deep['instance'])->toBe('/api/v1/orders/42/lines/7') + ->and($root['instance'])->toBe('/'); +}); + +it('carries about:blank as its problem type by default, the way Spring\'s ProblemDetail does', function () { + $payload = json_decode((string) (new ProblemDetailsRenderer)->render(new ResourceNotFoundException('x'), Request::create('/api/x'))->getContent(), true); + + /** @var array $payload */ + expect($payload['type'])->toBe('about:blank') + // The member leads the document with the other standard ones, whatever extensions arrived. + ->and(array_slice(array_keys($payload), 0, 5))->toBe(['status', 'title', 'code', 'category', 'severity']); +}); + +it('derives a dereferenceable type from the stable code when the deployment names a base', function () { + $renderer = new ProblemDetailsRenderer(ErrorPageSettings::fromConfig(new Config(new Repository([ + 'firefly' => ['web' => ['problem' => ['type-uri' => 'https://api.example.test/problems']]], + ])))); + + $payload = json_decode((string) $renderer->render(new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'), Request::create('/api/orders/42'))->getContent(), true); + + /** @var array $payload */ + expect($payload['type'])->toBe('https://api.example.test/problems/order-not-found') + ->and($payload['code'])->toBe('ORDER_NOT_FOUND') + ->and($payload['instance'])->toBe('/api/orders/42'); +}); + +it('emits no type member at all when a deployment wants the pre-9457 document byte for byte', function () { + $renderer = new ProblemDetailsRenderer(ErrorPageSettings::fromConfig(new Config(new Repository([ + 'firefly' => ['web' => ['problem' => ['type-uri' => '']]], + ])))); + + $payload = json_decode((string) $renderer->render(new ResourceNotFoundException('x'), Request::create('/api/x'))->getContent(), true); + + /** @var array $payload */ + expect($payload)->not->toHaveKey('type'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 46d4ef20..bfef4609 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1115,6 +1115,29 @@ // | Default: false. Turn it on deliberately, on a machine where the payload is yours to read. // */ // 'disclose' => false, + // + // /* + // | RFC 9457 §3.1.1's `type` member: the URI reference that says WHAT KIND of problem this is, + // | as opposed to `instance`, which identifies this one occurrence of it. + // | + // | THE RFC MAKES THIS A PRESENTATION CHOICE, NOT A CONFORMANCE ONE: §3.1.1 says an absent + // | `type` is identical to `about:blank`, so a document without one has always been conformant. + // | Spring Boot's ProblemDetail nevertheless serialises `about:blank` explicitly, and that is + // | the default here for the same reason: a client reading `type` always finds a string and + // | never has to know the RFC's equivalence rule to work out what its absence meant. + // | + // | THREE BEHAVIOURS, ONE KEY. 'about:blank' emits the RFC's own "no specific type". '' omits + // | the member entirely, which is the pre-9457 document byte for byte. And a BASE URI derives a + // | real, dereferenceable type from the stable error code every Firefly failure already carries + // | — 'https://api.example.test/problems' turns a RESOURCE_NOT_FOUND into + // | 'https://api.example.test/problems/resource-not-found', which a client can switch on and a + // | person can open, built from the same identifier the log line and the support ticket quote. + // | A code that slugs to nothing falls back to 'about:blank' rather than to the bare base: the + // | base names the COLLECTION of problem types, not a member of it. + // | + // | Default: 'about:blank'. + // */ + // 'type-uri' => env('FIREFLY_WEB_PROBLEM_TYPE_URI', 'about:blank'), // ], // // /* From 59850f7365090ee5b86e9f5c21e4bd2ac5edb034 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 21:41:53 -0700 Subject: [PATCH 56/78] fix(admin): size the data browser's columns for the headers a schema derives, emit its colgroup from TableView, and say what its page-size cap really does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review of Task 10, all of them about the listing this wave moved onto the shared table system. The rigid
    widths were chosen from the column TYPE alone. Every other .ftable listing writes its own labels beside hand-tuned widths, but this page humanises a database column name — DataColumn::label() turns `failed_login_attempts` into `Failed login attempts` — so nobody ever saw the label the width had to hold. Measured in Chromium against these rules, that header renders 184px inside a 126px box and reads `FAILED LOGIN ATTE`: `thead th` is nowrap, `table.ftable td,th` is overflow:hidden with no ellipsis, and under table-layout:fixed the column cannot grow the way the table-layout:auto this wave replaced did. A rigid column is now widened to the greater of its type's alphabet and what its own header needs, through TableColumn::fittingItsHeader() — 1.25ch per character plus 1.75 for the ordering indicator, both measured and both documented beside the numbers TableColumn already carries — and the - + @foreach ($detail->caller as $binding) - + - + - + @endforeach @@ -105,7 +105,8 @@

    Response

    Default success status {{ $route->status }}. A returned response can override it.

    -

    {{ $route->html ? 'The controller is declared as an HTML page (text/html).' : 'Negotiated from Accept and the returned value’s type. Older manifests may not record the HTML stereotype.' }}

    +

    {{ $route->html ? 'The manifest records the #[Controller] HTML stereotype. This is declaration metadata, not a media-type guarantee.' : 'No HTML stereotype is recorded. Older manifests may omit this metadata.' }}

    +

    The returned value and Accept determine the response. Views and HTML-capable values can render as HTML; data is negotiated; returned responses retain their own headers.

    @if ($route->html)

    HTML controllers are excluded from the OpenAPI document unless firefly.openapi.include-html is enabled.

    @endif

    Exception handlers

    Controller-local handlers take precedence over global handlers; within that scope, the most-derived matching exception class wins.

    diff --git a/packages/admin/src/Route/RouteBinding.php b/packages/admin/src/Route/RouteBinding.php index afe861db..504b2f12 100644 --- a/packages/admin/src/Route/RouteBinding.php +++ b/packages/admin/src/Route/RouteBinding.php @@ -27,6 +27,8 @@ public function supplied(): bool public function defaultLabel(): string { - return json_encode($this->plan['default'], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) ?: 'null'; + $encoded = json_encode($this->plan['default'], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + + return $encoded !== false ? $encoded : 'null'; } } diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 2d072726..7c04fd20 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -178,14 +178,14 @@ private function mappingLinks(RouteDetail $detail): array } foreach ($graph->edges as $edge) { if ($edge['from'] === $detail->route->controllerClass && isset($properties[$edge['to']])) { - $links[] = ['label' => 'Configuration: '.Format::leafOf($edge['to']), 'url' => $this->settings->url('configprops').'?q='.rawurlencode($edge['to'])]; + $links[] = ['label' => 'Configuration: '.Format::leafOf($edge['to']), 'url' => $this->configurationLink($edge['to'], $properties[$edge['to']])]; } } } foreach ($detail->injected as $binding) { $type = $binding->plan['type']; if ($binding->resolver === null && $type !== null && isset($properties[$type])) { - $links[] = ['label' => 'Configuration: '.Format::leafOf($type), 'url' => $this->settings->url('configprops').'?q='.rawurlencode($type)]; + $links[] = ['label' => 'Configuration: '.Format::leafOf($type), 'url' => $this->configurationLink($type, $properties[$type])]; } } $router = $this->container->bound('router') ? $this->container->make('router') : null; @@ -197,6 +197,13 @@ private function mappingLinks(RouteDetail $detail): array return $links; } + private function configurationLink(string $class, mixed $description): string + { + $bound = is_array($description) && ($description['bound'] ?? false) === true; + + return $this->settings->url('configprops').'?'.($bound ? 'props_q' : 'unbound_q').'='.rawurlencode($class); + } + private function settingsPage(AdminPage $current): SymfonyResponse { if (! $this->console->isEnabled()) { diff --git a/packages/admin/tests/Route/RouteInspectorTest.php b/packages/admin/tests/Route/RouteInspectorTest.php index 69c940a7..3f40f3f8 100644 --- a/packages/admin/tests/Route/RouteInspectorTest.php +++ b/packages/admin/tests/Route/RouteInspectorTest.php @@ -141,3 +141,8 @@ public function resolve(array $binding, Request $request): mixed $binding = new RouteBinding(1, [...detailBinding('orderId', 'path'), 'key' => 'id', 'pattern' => '[0-9]+'], null); expect($binding->notFoundMessage)->toBe(ArgumentResolver::notFoundSentence('orderId')); }); + +it('distinguishes zero null false and empty defaults in the compiled contract', function (mixed $default, string $label) { + $binding = new RouteBinding(1, [...detailBinding('limit', 'query', 'int', false), 'default' => $default], null); + expect($binding->defaultLabel())->toBe($label); +})->with([[0, '0'], [null, 'null'], [false, 'false'], ['', '""'], [[], '[]']]); diff --git a/packages/admin/tests/RouteDetailPageTest.php b/packages/admin/tests/RouteDetailPageTest.php index ffd47897..53267fc0 100644 --- a/packages/admin/tests/RouteDetailPageTest.php +++ b/packages/admin/tests/RouteDetailPageTest.php @@ -7,18 +7,25 @@ use Firefly\Admin\AdminSettings; use Firefly\Admin\Tests\Support\AdminTableCapstoneTestCase; use Firefly\Admin\Tests\Support\Fixtures\BillingProperties; +use Firefly\Admin\Tests\Support\Fixtures\LedgerProperties; use Firefly\Config\Config; +use Firefly\Config\Scanner\ConfigPropertiesDescriptor; +use Firefly\Config\Scanner\ConfigPropertiesManifest; use Firefly\Data\Proxy\ProxyPlan; use Firefly\Kernel\Exception\Business\ValidationException; use Firefly\Web\Dispatch\HandlerMethodArgumentResolver; use Firefly\Web\Dispatch\HandlerMethodArgumentResolvers; +use Firefly\Web\Dispatch\ResponseFactory; use Firefly\Web\Exception\ExceptionHandlerDescriptor; use Firefly\Web\Exception\ExceptionHandlerRegistry; +use Firefly\Web\Http\JsonMessageConverter; +use Firefly\Web\Http\MessageConverterRegistry; use Firefly\Web\Route\RouteDescriptor; use Firefly\Web\Route\RouteManifest; use Illuminate\Contracts\Config\Repository; use Illuminate\Http\Request; use Illuminate\Routing\Router; +use Illuminate\Support\HtmlString; uses(AdminTableCapstoneTestCase::class); @@ -142,7 +149,46 @@ public function resolve(array $binding, Request $request): mixed new ExceptionHandlerDescriptor(Throwable::class, 'App\\GlobalAdvice', 'globalFailure', true), new ExceptionHandlerDescriptor(Throwable::class, 'App\\Unrelated', 'unrelatedFailure', false), ])); - $this->get('/firefly/mappings?route=GET%20%2F')->assertOk()->assertSee('HTML page') + $this->get('/firefly/mappings?route=GET%20%2F')->assertOk()->assertSee('HTML stereotype')->assertSee('Declared') ->assertSee('localFailure')->assertSee('globalFailure')->assertDontSee('unrelatedFailure') ->assertSee('firefly.openapi.include-html'); }); + +it('follows configuration joins to the correctly filtered bound or unbound panel', function (bool $bound) { + /** @var AdminTableCapstoneTestCase $this */ + $type = $bound ? BillingProperties::class : LedgerProperties::class; + if ($bound) { + $this->app()->instance(LedgerProperties::class, new LedgerProperties); + } + $this->app()->instance(ConfigPropertiesManifest::class, new ConfigPropertiesManifest([ + new ConfigPropertiesDescriptor(BillingProperties::class, 'billing'), + new ConfigPropertiesDescriptor(LedgerProperties::class, 'ledger'), + new ConfigPropertiesDescriptor(stdClass::class, 'unrelated'), + ])); + $this->app()->instance(RouteManifest::class, new RouteManifest([ + new RouteDescriptor('GET', '/configuration-link', 'App\\Configured', 'index', 200, null, [['name' => 'config', 'kind' => 'service', 'key' => 'config', 'type' => $type, 'required' => true, 'default' => null, 'valid' => false, 'properties' => []]]), + ])); + $body = (string) $this->get('/firefly/mappings?route=GET%20%2Fconfiguration-link')->assertOk()->getContent(); + preg_match('/href="([^"]+)">Configuration:/', $body, $matches); + $url = html_entity_decode($matches[1] ?? ''); + expect($url)->toContain($bound ? 'props_q=' : 'unbound_q='); + $destination = $this->get($url)->assertOk(); + if ($bound) { + $destination->assertSee('BillingProperties')->assertDontSee('LedgerProperties')->assertSee('3 total'); + } else { + $destination->assertSee('LedgerProperties')->assertDontSee('stdClass')->assertSee('1 total'); + } +})->with([true, false]); + +it('describes HTML as declaration metadata because response negotiation follows the returned value', function (bool $html) { + /** @var AdminTableCapstoneTestCase $this */ + $descriptor = new RouteDescriptor('GET', '/mixed-response', 'App\\MixedController', 'show', 200, null, [], html: $html); + $factory = new ResponseFactory(new MessageConverterRegistry([new JsonMessageConverter])); + $request = Request::create('/mixed-response', server: ['HTTP_ACCEPT' => 'application/json']); + expect($factory->make(['ok' => true], $descriptor, $request)->headers->get('Content-Type'))->toBe('application/json') + ->and($factory->make(new HtmlString('

    A view-like return

    '), $descriptor, $request)->headers->get('Content-Type'))->toBe('text/html; charset=UTF-8'); + $this->app()->instance(RouteManifest::class, new RouteManifest([$descriptor])); + $this->get('/firefly/mappings?route=GET%20%2Fmixed-response')->assertOk() + ->assertSee('HTML stereotype')->assertSee('The returned value and Accept determine the response.') + ->assertDontSee('HTML page (text/html)'); +})->with([true, false]); diff --git a/tests/Browser/RouteDetailTest.php b/tests/Browser/RouteDetailTest.php index c68984dc..00566fc6 100644 --- a/tests/Browser/RouteDetailTest.php +++ b/tests/Browser/RouteDetailTest.php @@ -32,15 +32,16 @@ }); it('contains the wide binding table inside its scrollport on a phone', function (): void { - $page = visit('/firefly/mappings?route=POST%20%2Forders')->on()->mobile(); + $page = visit('/firefly/mappings?route=POST%20%2Forders')->on()->mobile() + ->assertSee('Request body'); $page - ->assertSee('Request body') ->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true) ->assertScript("document.querySelector('.route-bindings').closest('.tw').scrollWidth > document.querySelector('.route-bindings').closest('.tw').clientWidth", true) ->assertNoJavaScriptErrors() ->screenshot(filename: 'route-detail-phone'); $page->script("document.getElementById('route-request').scrollIntoView()"); - $page->screenshot(filename: 'route-detail-phone-contract'); + $page->assertScript("document.getElementById('route-request').getBoundingClientRect().top < window.innerHeight", true) + ->screenshot(fullPage: false, filename: 'route-detail-phone-contract'); }); it('renders the route contract and failure explanations in the dark theme', function (): void { From caf703c755ebf13a29495a8dfb4bf8593ee11133 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 29 Sep 2026 13:46:08 -0700 Subject: [PATCH 74/78] feat(admin): render navigable bounded bean explorer Replace the whole-graph camera with server-rendered focus columns, native links, complete typed catalogues, module ports and bounded relation, cycle and root-path listings. Preserve factory identities, distinct contracts, honest ambiguity and distinct-neighbour degrees. Document every tunable and the legacy graphMaxNodes migration in both books. The historical dossier predates the shared typed table and understates the live hover half of the global binder; reuse the current table implementation and remove both old graph bindings. Replace old self-edge suppression with cycle coverage and pair-only deduplication with distinct-contract coverage. Existing table assertions now include accessible sort and paging attributes without reducing behavior coverage. Validation: composer check exited 0: 4132 passed, 6 skipped, 16766 assertions; Pint, PHPStan max and Deptrac passed. Chromium browser suite exited 0: 110 passed, 965 assertions. Focus, dark, mobile drawing, dense relations and module screenshots inspected. --- CHANGELOG.md | 9 + book/src-es/11-observability-actuator.md | 130 +---- book/src/11-observability-actuator.md | 132 +---- docs/modules/admin.md | 12 +- docs/modules/bean-graph.md | 294 +++------- .../admin/resources/views/_bx-beans.blade.php | 6 + .../resources/views/_bx-drawing.blade.php | 35 ++ .../admin/resources/views/_pager.blade.php | 6 +- .../resources/views/_table-head.blade.php | 4 +- .../admin/resources/views/beans.blade.php | 12 +- .../admin/resources/views/graph.blade.php | 532 ++++-------------- .../admin/resources/views/layout.blade.php | 145 +---- packages/admin/src/AdminSettings.php | 6 +- packages/admin/src/BeanGraph.php | 20 +- packages/admin/src/BeanGraphIndex.php | 49 +- packages/admin/src/BeanModules.php | 38 ++ packages/admin/src/BeanNeighbourhood.php | 4 + packages/admin/src/ExplorerQuery.php | 60 ++ packages/admin/src/Web/AdminAction.php | 171 +++++- packages/admin/src/Web/AdminPage.php | 2 +- packages/admin/tests/AdminSettingsTest.php | 13 + .../tests/AdminTableConditionsPagerTest.php | 10 +- .../tests/AdminTableConfigPropsPagerTest.php | 12 +- .../admin/tests/AdminTableListingTest.php | 22 +- packages/admin/tests/AdminTablePagerTest.php | 14 +- packages/admin/tests/BeanExplorerPageTest.php | 179 ++++++ packages/admin/tests/BeanExplorerTest.php | 58 ++ packages/admin/tests/BeanGraphTest.php | 5 +- 28 files changed, 903 insertions(+), 1077 deletions(-) create mode 100644 packages/admin/resources/views/_bx-beans.blade.php create mode 100644 packages/admin/resources/views/_bx-drawing.blade.php create mode 100644 packages/admin/src/ExplorerQuery.php create mode 100644 packages/admin/tests/BeanExplorerPageTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0412ee..4679f8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y ### Added +- **Bean explorer:** server-rendered landing/search/focus/module states, native keyboard links, bounded hop + columns, exact overflow links, complete paginated catalogue and relations, module coupling metrics, + conditions and shortest entry-point chains. Iterative SCC analysis handles deep graphs and self-cycles. +- **Bean graph truthfulness:** stable competing factory identities across configurations, explicit unresolved + ambiguity instead of an arbitrary target, unknown factory scope and exclusion of unbound config DTOs. +- **Explorer settings:** focus depth/row/node/path/page budgets, starter/module budgets and catalogue page size. + The legacy `firefly.admin.graph.max-nodes` is still parsed but no longer controls drawing; **0 no longer + forces a list**. Use the catalogue or relation tables for tabular exploration. + - **Error navigation:** configured sign-in on 401, retry on GET/HEAD 5xx, and home/support links where configured. Production ledes retain safe authored details and 405 pages name the allowed methods. - **Error configuration:** documented `max-frames`, `home`, `sign-in`, `support`, `actions`, `copy-button`, diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index a4fea674..9a407d73 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -933,133 +933,21 @@ Un endpoint que lanza se captura y se reporta como `null` en vez de dejar que se --- -### El grafo de beans +### El explorador de beans -La mayoría de las páginas son tablas. Dos dibujan una imagen — esta, y el [mapa de entidades](#recorrer-el-modelo-relaciones-filtros-y-un-mapa) más adelante en el capítulo — y esta es la que amortiza el paquete el día en que algo está mal cableado. +`/firefly/graph` abre con búsqueda, puntos de entrada, beans con más dependientes, acoplamiento entre módulos y ciclos del cableado. `?bean=` muestra un entorno acotado; `?module=` muestra los beans del módulo y sus relaciones externas; `?q=` busca en el catálogo completo. `/firefly/beans` incluye componentes, productos de fábrica y DTOs de configuración enlazados, ordenados inicialmente por número de dependientes descendente. -`/actuator/beans` te dice *qué* beans existen. No puede decirte a qué está **cableado** cada uno, que es lo que realmente quieres cuando un `#[ConditionalOnMissingBean]` no se disparó como esperabas, cuando un ciclo entre singletons ansiosos ha colgado un arranque sin mensaje alguno, o cuando intentas averiguar a qué se enganchó el paquete que acabas de instalar. `/firefly/graph` responde a eso, como un diagrama SVG por capas más una tabla de relaciones filtrable. +El bean seleccionado queda entre sus dependientes a la izquierda y sus dependencias a la derecha. PHP calcula las posiciones: cajas de 176×38 píxeles, separadas cada 204×46 píxeles, con un máximo predeterminado de 16 filas por columna y 72 nodos. Dos saltos ocupan 992 píxeles de ancho y hasta 728 de alto. Las cabeceras se desplazan con las columnas. Los enlaces nativos, formularios GET, paginación y foco visible de teclado funcionan sin JavaScript; no hay cámara ni ajuste de escala. Texto y siglas identifican los módulos además del color decorativo. -No se refleja nada para construirlo. `ComponentScanner` ya registra, en tiempo de **escaneo**, los tipos de clase e interfaz que pide el constructor de cada componente, y esa lista viaja en el manifiesto compilado igual que cualquier otro hecho escaneado (abreviado): +La selección reparte cada columna por turnos entre los nodos de la frontera. Los enlaces de desbordamiento abren las tablas completas y paginadas de vecinos directos del origen, con interfaz y tipo de relación. Las cadenas más cortas desde puntos de entrada explican la alcanzabilidad. Se conservan todas las condiciones positivas y negativas, incluidas las de la configuración productora; la ausencia del endpoint Conditions se tolera. - -```php -final readonly class ComponentDescriptor -{ - // … - public function __construct( - public string $class, - public string $stereotype, - // … - public array $interfaces, - // … - /** - * The class types this component's constructor asks for — the edges of the bean graph. - * - * Recorded at scan time, where reflection is already sanctioned, because the alternative is - * reflecting at request time to answer "what depends on what", which the reflection-free boot - // … - */ - public array $dependencies = [], - ) {} -// … -} -``` - -Esa última frase es una decisión de diseño en la que merece la pena detenerse. Un parámetro de constructor tipado `string $name` es configuración; dibujarlo como una arista enterraría las relaciones que importan bajo ruido de `string`/`int`. Un parámetro de **clase anulable o con valor por defecto** *sí* se conserva, porque un colaborador opcional sigue siendo una relación. - -#### Tres clases de nodo, y por qué la primera versión estaba casi vacía - -Antes que las aristas, los nodos — porque la primera versión de esta página los entendió mal de una forma de la que merece la pena aprender. Una aplicación LaraFly tiene **tres clases de bean**, y las tres tienen que ser nodos: - -| Clase | Qué es | De dónde sale | -|---|---|---| -| `component` | Una clase escaneada `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` | El catálogo de beans | -| `bean` | Un valor **producido por un método fábrica `#[Bean]`** de una `#[Configuration]` | Las filas `produces` del catálogo | -| `config` | Un DTO `#[ConfigProperties]` enlazado desde la configuración | El endpoint `configprops` | - -Al principio solo la primera clase era un nodo, y la consecuencia no fue cosmética. El cableado de un framework vive casi por completo en la segunda clase: una autoconfiguración es una `#[Configuration]` cuyos métodos `#[Bean]` producen `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` y demás. Con solo las clases declarantes como nodos, cada arista que apuntaba a uno de esos productos apuntaba a un nodo que no existía. Medido sobre un esqueleto de serie: **42 nodos, 41 productos `#[Bean]` ausentes, 21 dependencias colgando y exactamente una arista dibujada.** La página no mostraba un grafo disperso — era estructuralmente incapaz de mostrar el cableado del framework. - -La tercera clase es el mismo error en miniatura. Un DTO `#[ConfigProperties]` está enlazado y es inyectable, pero no se escanea como componente ni lo produce una fábrica, así que nada en el catálogo de beans puede verlo: `App\GreetingProperties` aparecía como *dependencia no resuelta* de `GreetingService` en lugar de como el bean que es. Por eso la página lee el endpoint `configprops` junto al catálogo. - -De modo que también hay dos clases de arista, y dicen cosas distintas: - -| Arista | De → a | Significado | -|---|---|---| -| `injects` | Un bean → algo de lo que declaró depender | El consumidor lo pidió; el contenedor lo satisface | -| `produces` | Una `#[Configuration]` → el valor que devuelve uno de sus métodos `#[Bean]` | Esta clase es de donde sale ese bean | - -Las aristas `injects` se recogen de los parámetros del **constructor** de un componente *y* de los parámetros de cada **método fábrica `#[Bean]`** — el producto depende de lo que su fábrica pidiera. Esa unión es el cableado; los constructores por sí solos son una fracción de él. - -Una sutileza sobre la identidad. Un producto `#[Bean]` se identifica normalmente por el **tipo que produce**, porque esa es la clave que enlaza el contenedor y la clave que pide todo consumidor. Pero cuando dos métodos fábrica producen el mismo tipo — la forma que las reglas `#[Primary]`/`#[Qualifier]` del Capítulo 2 existen para desambiguar — el tipo por sí solo los colapsaría en un único nodo y ocultaría justo la ambigüedad por la que abriste la página. Así que cada competidor recibe `Declarante::metodo()` como identidad propia y el tipo desnudo resuelve al primero de ellos, reflejando al contenedor, donde la clave de tipo es un alias del ganador mientras todo candidato sigue alcanzable por nombre. - -#### Lo difícil no es dibujar, es resolver - -Un constructor pide un **tipo**, y ese tipo es muy a menudo una interfaz — `EventPublisher`, `HealthIndicator`, `Cache` — mientras que el bean que lo satisface es una clase concreta que meramente la implementa. Una lista de aristas construida ingenuamente a partir de los tipos del constructor apunta entonces a nodos que no existen, y el grafo sale como un campo de puntos desconectados. Pregúntate a qué debería dibujar una flecha la dependencia de `WalletService` sobre `WalletRepository`: no al puerto, que es una interfaz sin bean propio, sino a `EloquentWalletRepository`, que es lo que de verdad se va a construir. - -Así que cada dependencia se resuelve a través de un índice de interfaces antes de convertirse en arista: - - -```php -foreach ($entry['dependencies'] as $dependency) { - $target = $this->resolve($dependency); - - if ($target === null) { - $unresolved[] = $dependency; - - continue; - } - // … - $edges[] = [ - 'from' => $entry['from'], - 'to' => $target, - 'via' => $target === $dependency ? null : $dependency, - 'type' => $entry['type'], - ]; -} -``` - - -```php -private function resolve(string $type): ?string -{ - return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null); -} -``` - -El miembro `via` es la honestidad de ese bucle. Cuando la arista pasó por una interfaz, el diagrama la marca y la columna **Wired by** de la tabla de relaciones nombra la interfaz, de modo que quien lee ve la indirección en lugar de que se le muestre calladamente una relación que nunca escribió. Cuando el constructor nombró la clase concreta, la columna simplemente dice `class`. - -El índice se construye en orden de catálogo y **gana el primer implementador**, de forma determinista — el catálogo se emite en orden de escaneo, así que la misma aplicación dibuja siempre el mismo grafo en lugar de rebarajarse entre máquinas. Una interfaz con varios implementadores es una ambigüedad real que el contenedor resuelve con `#[Primary]`/`#[Qualifier]`, y el grafo lo dice listando la arista como `via` en lugar de fingir que la elección era obvia. - -#### Capas, ciclos y el techo de nodos - -Los niveles salen de un recorrido de **camino más largo** sobre las aristas resueltas: la profundidad de un nodo es uno más que la de lo más profundo de lo que depende, y después los niveles se invierten para que el nivel 0 contenga aquello de lo que nada depende. El efecto es que un nodo siempre queda por debajo de todo lo que depende de él, las flechas se leen consistentemente hacia abajo, y la vista puede seguir una cadena desde un controlador hasta el repositorio que hay al final. La vista solo posiciona; los niveles vienen del modelo. - -La profundidad se memoiza y el recorrido lleva su propio conjunto de visitados, así que un ciclo termina en lugar de recursar para siempre — y la arista que lo cerró se *reporta*: - - -```php -foreach ($out[$node] ?? [] as $next) { - if (isset($path[$next])) { - $cycles[] = ['from' => $node, 'to' => $next]; - - continue; - } - $deepest = max($deepest, $walk($next, $path) + 1); -} -``` - -Ese reporte vale más de lo que parece. El contenedor no tiene detección de ciclos propia, así que un ciclo entre singletons ansiosos no produce un error útil — agota la memoria en el arranque. Una página que nombra las dos clases implicadas convierte "la app murió sin mensaje" en un diagnóstico de cinco segundos, y el consejo del propio panel es el correcto: rompe una de estas aristas, normalmente inyectando una interfaz y dejando que el otro lado dependa de ella. - -Dos límites se declaran en la página en lugar de ocultarse: +Cada relación entre módulos muestra peso (relaciones resueltas), beans destino distintos, interfaces distintas y relaciones concretas. Las aristas de producción se excluyen salvo petición explícita. Las listas siguen paginadas cuando se supera el presupuesto del mapa. El módulo señala la alcanzabilidad exclusiva y los beans sin dependientes; son observaciones del catálogo, no una garantía de que retirar un módulo sea seguro. -* **Pasados los `firefly.admin.graph.max-nodes` — 220 por defecto — el diagrama se suprime** y la tabla de Relaciones de abajo lleva la misma información como una lista filtrable. Un diagrama de más de un par de centenares de nodos es una maraña, no algo que una persona pueda leer, y renderizarlo de todos modos sería peor respuesta que negarse. Es una clave de configuración y no una constante porque "ilegible" depende de la pantalla y de la aplicación. -* **"Provided outside the container" no es una advertencia.** Esas etiquetas son tipos de constructor satisfechos por un binding del contenedor de Laravel y no por un bean escaneado — la `Request`, el repositorio de configuración, una conexión. Se listan en lugar de descartarse en silencio precisamente porque *"¿por qué no está mi bean en el grafo?"* es la pregunta que la página tiene que responder. Un tipo que aparezca ahí y que esperabas que fuera un bean *tuyo* significa que tu escaneo no lo vio, y `firefly.scan.paths` es lo primero que hay que revisar. +Tarjan iterativo identifica componentes fuertemente conexos, incluida la autoinyección, sin recorridos recursivos. Un ciclo que incluye producción de fábricas no implica necesariamente un ciclo de constructores en ejecución. Las fábricas competidoras mantienen identidades separadas incluso en configuraciones diferentes. La proyección no aporta los metadatos primary/qualifier necesarios: los destinos ambiguos quedan sin resolver y el ámbito de fábrica se muestra como no informado. Se excluyen los DTOs explícitamente no enlazados. No se instancia ningún bean para inspeccionarlo. -!!! tip "Léelo junto a la página de Condiciones" - Las dos responden mitades complementarias de toda sorpresa de auto-configuración. **Condiciones** dice *si* un bean del framework se registró o se echó atrás, y sobre qué condición. **El grafo** dice a qué está cableado el bean que sí ganó, y a través de qué interfaz. Una arista `EventPublisher` apuntando a `InMemoryEventPublisher` cuando configuraste `firefly.eda.provider=rabbitmq` se ve de un vistazo en el grafo; Condiciones nombra entonces el `#[ConditionalOnProperty]` que no casó. +**Migración:** `firefly.admin.graph.max-nodes` sigue leyéndose en `AdminSettings::$graphMaxNodes` (220 por defecto), pero ya no controla el dibujo. **0 ya no fuerza una lista**. Para una vista tabular utiliza el catálogo completo o las tablas de relaciones. -!!! note "Lo que el grafo sigue sin decidir por ti" - Dos límites merecen conocerse, y ninguno es una carencia de datos. **`#[Primary]`/`#[Qualifier]` no dirigen el índice** — gana quien escriba primero en orden de escaneo, tanto para una interfaz con varios implementadores como para la clave de tipo desnuda de un `#[Bean]` disputado. Cada competidor sigue teniendo su propio nodo y la arista se marca `via`, así que la ambigüedad es visible en la página, pero el destino dibujado puede no ser el que resuelve el contenedor. Y **un tipo no resuelto se reporta, nunca se explica**: la página puede decirte que un tipo lo provee algo fuera del contenedor, pero no *qué* enlace lo provee, porque un enlace del contenedor de Laravel no lleva descriptor que leer. +Las claves nuevas son `firefly.admin.graph.focus.depth` (2, límites 1–4), `focus.max-rows` (16, 4–60), `focus.max-nodes` (72, 8–300), `focus.max-paths` (3, 0–10), `focus.page-size` (50, 10–500), `firefly.admin.graph.starters` (12, 1–50), `firefly.admin.graph.modules.max-nodes` (40, 0–200; 0 desactiva el mapa) y `firefly.admin.beans.page-size` (50, 10–500). Los tamaños de página también respetan los límites compartidos de tablas. Todas están documentadas en `skeleton/config/firefly.php`. --- @@ -1233,7 +1121,7 @@ Es un *interruptor de funcionalidad*, no un endpoint de configuración remota: l | `ObservabilityAutoConfiguration` `#[Order(500)]` | El mismo truco de precedencia que la costura de seguridad del Capítulo 10: registra `cqrsMetrics()` antes de que `CqrsAutoConfiguration` evalúe su `#[ConditionalOnMissingBean]` | | `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse | | `AdminEndpointReader` | Invoca cada `ActuatorEndpoint` **en proceso** desde el `ActuatorRegistry`, sorteando `ExposureModel` — así el panel muestra lo que la superficie HTTP no expone, y un endpoint que lanza degrada un solo panel | -| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) | +| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y exploración acotada por saltos con SCCs iterativos | | Los productos `#[Bean]` como nodos | El cableado de un framework vive en métodos fábrica, no en constructores; con solo las clases declarantes como nodos, un esqueleto de serie dibujaba **una** arista de 42 beans | | `ComponentDescriptor::$dependencies` | Las aristas del grafo, registradas por `ComponentScanner` en tiempo de **escaneo** — solo tipos de clase e interfaz, porque un parámetro escalar es configuración, no cableado | | `firefly.admin.enabled` | Toma por defecto `app.debug`; un valor explícito gana en ambas direcciones, y encenderlo con debug apagado te obliga a poner tu propio middleware de autenticación delante de la ruta | diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index c437f4ee..aa2c993f 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -864,7 +864,7 @@ Most pages are a view over one endpoint's payload; four read the container inste | Runtime | Metrics | `metrics` | Counters, timers and gauges, with their current measurements | | Runtime | HTTP traffic | `httpexchanges` | The most recent requests this application served | | Wiring | Beans | `beans` | Every bean the container registered, with the stereotype that declared it | -| Wiring | Bean graph | `beans` | How your beans depend on one another, resolved through the interfaces they are wired by | +| Wiring | Bean explorer | `beans` | How your beans depend on one another, resolved through the interfaces they are wired by | | Wiring | Conditions | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own | | Wiring | Routes | `mappings` | The compiled route table the dispatcher serves from | | Wiring | Scheduled | `scheduledtasks` | Methods registered by `#[Scheduled]`, with the cron or interval that drives them | @@ -933,133 +933,21 @@ A throwing endpoint is caught and reported as `null` rather than allowed to take --- -### The bean graph +### The bean explorer -Most of the pages are tables. Two draw a picture — this one, and the [entity map](#walking-the-model-relations-filters-and-a-map) later in the chapter — and this is the one that pays for the package on the day something is wired wrongly. +`/firefly/graph` now opens with search, entry points, heavily depended-on beans, module coupling and wiring cycles. Select `?bean=` to explore a bounded neighbourhood; `?module=` opens that module's beans and boundary relations; `?q=` searches the complete catalogue. `/firefly/beans` includes components, factory products and bound configuration DTOs and defaults to dependent count descending. -`/actuator/beans` tells you *which* beans exist. It cannot tell you what each one is **wired to**, which is what you actually want when a `#[ConditionalOnMissingBean]` did not fire the way you expected, when an eager singleton cycle has hung a boot with no message, or when you are trying to work out what a package you just installed attached itself to. `/firefly/graph` answers that, as a layered SVG diagram plus a filterable relations table. +The selected bean sits between its dependents on the left and dependencies on the right. Positions are computed in PHP: 176×38-pixel boxes at 204×46-pixel pitch, with at most 16 rows per column and 72 nodes by default. Two hops occupy 992 pixels horizontally and at most 728 vertically. Headings scroll with the content. Native links, GET forms, pagination and visible keyboard focus work without JavaScript; the graph has no camera or fit operation. Module text and sigils complement decorative colours. -Nothing is reflected to build it. `ComponentScanner` already records, at **scan** time, the class and interface types each component's constructor asks for, and that list rides the compiled manifest exactly like every other scanned fact (abridged): +Frontier candidates share each column round robin. Overflow links open the source bean's complete direct-neighbour tables, with interface indirection and injection/production labels. Shortest entry-point chains explain reachability. The details preserve all positive and negative conditions, including those on the producing configuration; a missing Conditions endpoint is tolerated. - -```php -final readonly class ComponentDescriptor -{ - // … - public function __construct( - public string $class, - public string $stereotype, - // … - public array $interfaces, - // … - /** - * The class types this component's constructor asks for — the edges of the bean graph. - * - * Recorded at scan time, where reflection is already sanctioned, because the alternative is - * reflecting at request time to answer "what depends on what", which the reflection-free boot - // … - */ - public array $dependencies = [], - ) {} -// … -} -``` - -That last sentence is a design decision worth pausing on. A constructor parameter typed `string $name` is configuration; drawing it as an edge would bury the relationships that matter under `string`/`int` noise. A **nullable or defaulted class** parameter *is* kept, because an optional collaborator is still a relationship. - -#### Three kinds of node, and why the first version was nearly empty - -Before the edges, the nodes — because the first version of this page got them wrong in a way worth learning from. A LaraFly application has **three kinds of bean**, and all three have to be nodes: - -| Kind | What it is | Where it comes from | -|---|---|---| -| `component` | A scanned `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` class | The beans catalogue | -| `bean` | A value **produced by a `#[Bean]` factory method** on a `#[Configuration]` | The catalogue's `produces` rows | -| `config` | A `#[ConfigProperties]` DTO bound from configuration | The `configprops` endpoint | - -Only the first kind was a node to begin with, and the consequence was not a cosmetic one. A framework's wiring lives almost entirely in the second kind: an auto-configuration is a `#[Configuration]` whose `#[Bean]` methods produce `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` and the rest. With only declaring classes as nodes, every edge pointing at one of those products pointed at a node that did not exist. Measured on a stock skeleton: **42 nodes, 41 `#[Bean]` products missing, 21 dangling dependencies, and exactly one edge drawn.** The page was not showing a sparse graph — it was structurally incapable of showing framework wiring at all. - -The third kind is the same mistake in miniature. A `#[ConfigProperties]` DTO is bound and injectable, but it is neither scanned as a component nor produced by a factory, so nothing in the beans catalogue can see it: `App\GreetingProperties` turned up as an *unresolved dependency* of `GreetingService` rather than as the bean it is. That is why the page reads the `configprops` endpoint alongside the catalogue. - -So there are two kinds of edge, too, and they say different things: - -| Edge | From → to | Meaning | -|---|---|---| -| `injects` | A bean → something it declared a dependency on | The consumer asked for it; the container satisfies it | -| `produces` | A `#[Configuration]` → the value one of its `#[Bean]` methods returns | This class is where that bean comes from | - -`injects` edges are collected from a component's **constructor** parameters *and* from every `#[Bean]` **factory method's** parameters — the product depends on whatever its factory asked for. That union is the wiring; constructors alone are a fraction of it. - -One subtlety about identity. A `#[Bean]` product is normally identified by the **type it produces**, because that is the key the container binds and the key every consumer asks for. But when two factory methods produce the same type — the shape that Chapter 2's `#[Primary]`/`#[Qualifier]` rules exist to disambiguate — the type alone would collapse them into a single node and hide exactly the ambiguity you opened the page to see. So each competitor gets `Declaring::method()` as its own id and the bare type resolves to the first of them, mirroring the container, where the type key aliases the winner while every candidate stays reachable by name. - -#### The hard part is not drawing, it is resolving - -A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, `HealthIndicator`, `Cache` — while the bean that satisfies it is a concrete class that merely implements it. An edge list built naively from constructor types therefore points at nodes that do not exist, and the graph comes out as a field of disconnected dots. Ask yourself what `WalletService`'s dependency on `WalletRepository` should draw an arrow *to*: not to the port, which is an interface with no bean of its own, but to `EloquentWalletRepository`, which is the thing that will actually be constructed. - -So every dependency is resolved through an interface index before it becomes an edge: - - -```php -foreach ($entry['dependencies'] as $dependency) { - $target = $this->resolve($dependency); - - if ($target === null) { - $unresolved[] = $dependency; - - continue; - } - // … - $edges[] = [ - 'from' => $entry['from'], - 'to' => $target, - 'via' => $target === $dependency ? null : $dependency, - 'type' => $entry['type'], - ]; -} -``` - - -```php -private function resolve(string $type): ?string -{ - return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null); -} -``` - -The `via` member is the honesty in that loop. When the edge went through an interface, the diagram marks it and the Relations table's **Wired by** column names the interface, so a reader can see the indirection rather than being quietly shown a relationship they never wrote. When the constructor named the concrete class, the column just says `class`. - -The index is built in catalogue order and **first implementor wins**, deterministically — the catalogue is emitted in scan order, so the same application always draws the same graph rather than reshuffling between machines. An interface with several implementors is a real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`, and the graph says so by listing the edge as `via` rather than pretending the choice was obvious. - -#### Layers, cycles, and the node ceiling - -Levels come from a **longest-path** walk over the resolved edges: a node's depth is one more than the deepest thing it depends on, and the levels are then flipped so level 0 holds the things nothing depends on. The effect is that a node always sits below everything that depends on it, arrows read consistently downward, and the eye can follow a chain from a controller to the repository at the bottom of it. The view only positions; the levels come from the model. - -Depth is memoised and the walk carries its own visited set, so a cycle terminates instead of recursing forever — and the edge that closed it is *reported*: - - -```php -foreach ($out[$node] ?? [] as $next) { - if (isset($path[$next])) { - $cycles[] = ['from' => $node, 'to' => $next]; - - continue; - } - $deepest = max($deepest, $walk($next, $path) + 1); -} -``` - -That reporting is worth more than it looks. The container has no cycle detection of its own, so a cycle among eager singletons does not produce a helpful error — it exhausts memory at boot. A page that names the two classes involved turns "the app died with no message" into a five-second diagnosis, and the panel's own advice is the right one: break one of these edges, usually by injecting an interface and letting the other side depend on that. - -Two limits are stated in the page rather than hidden: +Module coupling reports weight (resolved relations), distinct target beans, distinct interfaces and concrete relations. Production edges are excluded unless explicitly included. Module lists remain paginated above the map budget. The module page marks exclusive reachability and beans with no dependents. These are catalogue observations, not proof that removing a module is safe. -* **Past `firefly.admin.graph.max-nodes` — 220 by default — the diagram is suppressed** and the Relations table below carries the same information as a filterable list. A diagram past a couple of hundred nodes is a hairball, not something a person can read, and rendering one anyway would be a worse answer than declining to. It is a config key rather than a constant because "unreadable" depends on the screen and the application. -* **"Provided outside the container" is not a warning.** Those chips are constructor types satisfied by a Laravel container binding rather than a scanned bean — the `Request`, the config repository, a connection. They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the question the page has to answer. A type appearing there that you expected to be a bean of *yours* means your scan did not see it, and `firefly.scan.paths` is the first thing to check. +Iterative Tarjan SCC analysis reports all cycle members, including self-injection, without recursive walks. Since the wiring graph includes factory production, an SCC is not necessarily a runtime constructor cycle. Competing factory methods keep separate identities, even across configuration classes. The projection lacks primary/qualifier metadata, so ambiguous targets remain unresolved rather than guessing the container winner; factory scope is reported as unknown. Explicitly unbound configuration DTOs are excluded. No bean is instantiated to inspect it. -!!! tip "Read it next to the Conditions page" - The two answer complementary halves of every auto-configuration surprise. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition. **The graph** says what the bean that did win is wired to, and through which interface. An `EventPublisher` edge pointing at `InMemoryEventPublisher` when you configured `firefly.eda.provider=rabbitmq` is one glance on the graph; Conditions then names the `#[ConditionalOnProperty]` that did not match. +**Migration:** `firefly.admin.graph.max-nodes` still parses into `AdminSettings::$graphMaxNodes` (default 220), but no longer controls rendering. In particular, **0 no longer forces a list**. Use the complete catalogue or relation tables for a tabular view. -!!! note "What the graph still does not decide for you" - Two limits are worth knowing, and neither is a gap in the data. **`#[Primary]`/`#[Qualifier]` do not steer the index** — first writer in scan order wins, both for an interface with several implementors and for the bare type key of a contested `#[Bean]`. Every competitor still gets its own node and the edge is marked `via`, so the ambiguity is visible on the page, but the drawn target may not be the one the container resolves. And **an unresolved type is reported, never explained**: the page can tell you a type is provided outside the container, but not *which* binding provides it, because a Laravel container binding carries no descriptor to read. +The new settings are `firefly.admin.graph.focus.depth` (2, bounded 1–4), `focus.max-rows` (16, 4–60), `focus.max-nodes` (72, 8–300), `focus.max-paths` (3, 0–10), `focus.page-size` (50, 10–500), `firefly.admin.graph.starters` (12, 1–50), `firefly.admin.graph.modules.max-nodes` (40, 0–200; 0 disables the map), and `firefly.admin.beans.page-size` (50, 10–500). Relation/catalogue page sizes also obey shared table limits. All keys are documented in `skeleton/config/firefly.php`. --- @@ -1233,7 +1121,7 @@ It is a *feature switch*, not a remote configuration endpoint: the list is fixed | `ObservabilityAutoConfiguration` `#[Order(500)]` | The same precedence trick as Chapter 10's security seam: registers `cqrsMetrics()` before `CqrsAutoConfiguration` evaluates its `#[ConditionalOnMissingBean]` | | `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; a page whose endpoint is unregistered or switched off is hidden from the menu rather than linked | | `AdminEndpointReader` | Invokes each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`, bypassing `ExposureModel` — so the dashboard shows what the HTTP surface does not expose, and a throwing endpoint degrades one panel | -| `BeanGraph` | Turns the beans catalogue into a drawn dependency graph over **three kinds of node** — components, `#[Bean]` products and `#[ConfigProperties]` DTOs — with `injects`/`produces` edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and the diagram suppressed past `firefly.admin.graph.max-nodes` (220) | +| `BeanGraph` | Turns the beans catalogue into a drawn dependency graph over **three kinds of node** — components, `#[Bean]` products and `#[ConfigProperties]` DTOs — with `injects`/`produces` edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and bounded hop exploration with iterative SCCs | | `#[Bean]` products as nodes | A framework's wiring lives in factory methods, not constructors; with only declaring classes as nodes a stock skeleton drew **one** edge out of 42 beans | | `ComponentDescriptor::$dependencies` | The graph's edges, recorded by `ComponentScanner` at **scan** time — class and interface types only, because a scalar parameter is configuration, not wiring | | `firefly.admin.enabled` | Defaults to `app.debug`; an explicit value wins in both directions, and turning it on with debug off obliges you to put your own auth middleware in front of the route | diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 1c17041d..e092fdfc 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -35,7 +35,7 @@ is a worse menu than four short ones. | Runtime | Metrics | `/firefly/metrics` | `metrics` | Counters, timers and gauges, with their current measurements | | Runtime | HTTP traffic | `/firefly/http` | `httpexchanges` | The most recent requests this application served | | Wiring | Beans | `/firefly/beans` | `beans` | Every bean the container registered, with the stereotype that declared it | -| Wiring | **Bean graph** | `/firefly/graph` | `beans` | How your beans depend on one another — see [Bean Graph](bean-graph.md) | +| Wiring | **Bean explorer** | `/firefly/graph` | `beans` | How your beans depend on one another — see [Bean Explorer](bean-graph.md) | | Wiring | Conditions | `/firefly/conditions` | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own | | Wiring | Routes | `/firefly/mappings` | `mappings` | The compiled route table the dispatcher serves from | | Wiring | Scheduled | `/firefly/scheduled` | `scheduledtasks` | Methods registered by `#[Scheduled]`, with the cron or interval that drives them | @@ -280,7 +280,15 @@ Under PHP-FPM every request is a different process, and three pages inherit that | `firefly.admin.title` | `app.name` (else `'LaraFly'`) | The name shown in the sidebar and the page title. | | `firefly.admin.refresh-seconds` | `10` | How often a live page reloads itself. **Floored at 2**: a shorter interval reloads faster than the page renders, so the countdown would never finish and the dashboard would hammer the application it is meant to be observing. | | `firefly.admin.theme` | `'auto'` | `auto` \| `light` \| `dark`. Anything unrecognised falls back to `auto` (follow the operating system) rather than rendering unstyled. | -| `firefly.admin.graph.max-nodes` | `220` | The ceiling past which the [bean graph](bean-graph.md) lists relations instead of drawing them. Clamped to a minimum of `0`, which suppresses the diagram entirely. | +| `firefly.admin.graph.max-nodes` | `220` | Deprecated compatibility value. The [bean explorer](bean-graph.md) uses bounded focus views; this key, including `0`, no longer controls rendering. | +| `firefly.admin.graph.focus.depth` | `2` | Hops per side, clamped 1–4. | +| `firefly.admin.graph.focus.max-rows` | `16` | Column height budget, clamped 4–60. | +| `firefly.admin.graph.focus.max-nodes` | `72` | Drawing budget, clamped 8–300. | +| `firefly.admin.graph.focus.max-paths` | `3` | Entry-point chains, clamped 0–10; 0 hides chains. | +| `firefly.admin.graph.focus.page-size` | `50` | Relation rows, clamped 10–500 and capped by shared tables. | +| `firefly.admin.graph.starters` | `12` | Starter entries, clamped 1–50. | +| `firefly.admin.graph.modules.max-nodes` | `40` | Module overview limit, clamped 0–200; 0 lists only. | +| `firefly.admin.beans.page-size` | `50` | Complete catalogue rows, clamped 10–500 and capped by shared tables. | | `firefly.admin.table.page-size` | `50` | Rows per page on every listing. Always a member of `page-sizes` — a default the set does not contain is added to it, because a `
    puts its full label on `title`, because a character count cannot measure a font. The colgroup was a second mechanism beside the one packages/admin/src/Table already owns: literal `calc(19ch + 2 * var(--row-x))` strings in a Blade @php, with the 19 copied out of TableColumn::stamp()'s default under a comment claiming the two agreed that nothing enforced. AdminAction:: dataTableView() maps each DataColumn onto the TableColumn kind that knows its own width and the view emits TableView::widths(), so the 19 is that default rather than a copy of it and the text columns are the resolvable percentages the fixed-layout algorithm wants instead of `auto`. The `t-` classes stay on the cells — they are the database's types, which ColumnKind does not model — and the header row gains the `scope="col"` the shared _table-head partial emits. The dead `width:1%` rules the colgroup replaced are gone with their stale explanation. And the documented reference for firefly.admin.data.max-page-size still described a cap. TableSettings::clamp() is a closed set: on the listing `?size=300` is refused and falls back to 25 rather than being lowered to 200, and so is `?size=10`, which this page's own removed ` with `10` + among the literal options and cap whatever arrived; its listing query is now parsed against the shared + `firefly.admin.table.page-sizes` narrowed by `firefly.admin.data.max-page-size`, and that set is **closed** + — a size that is not a member falls back to `firefly.admin.data.page-size` instead of being clamped to the + nearest permitted one. So `?size=300` renders 25 rows rather than 200, and **a bookmark holding `?size=10` + renders 25 rather than 10**, because ten is no longer offered unless a deployment says so + (`FIREFLY_ADMIN_TABLE_PAGE_SIZES=10,25,50,100,200`, or `FIREFLY_ADMIN_DATA_PAGE_SIZE=10`, which forces its + own default into the set). `firefly.admin.data.max-page-size` is a plain cap only for a direct + `Firefly\Admin\Data\DataBrowser::list()` call, which is parsed against no query string. + ## [26.09.4] - 2026-09-23 The gaps a second real application had to work around, closed in the framework instead. Every entry below diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index 63fdccbf..06ea2f85 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -421,12 +421,15 @@ class name. The message stays in the exception, where a log can have it. | `firefly.admin.data.enabled` | **`false`** | Enable the browser at all — the `/firefly/data` pages, the entity map, and the `DataBrowser` API. Does **not** follow `app.debug` or `firefly.admin.enabled` — see [The two gates](#the-two-gates). | | `firefly.admin.data.writable` | **`false`** | Allow `update` and `delete`. Requires `enabled` as well; ineffective alone. | | `firefly.admin.data.page-size` | `25` | Default rows per page. Clamped into `[1, max-page-size]`. | -| `firefly.admin.data.max-page-size` | `200` | Ceiling applied to any caller-supplied page size. Itself capped at **1000**, because `?perPage=1000000` on a resource that cannot page is a request to materialise the table into PHP memory. | +| `firefly.admin.data.max-page-size` | `200` | The ceiling this browser narrows the **offered set** to — see [Rows per page](#rows-per-page) for what that means on the listing, where a `?size=` outside the set is *refused* rather than lowered. It is a plain cap only for a direct `DataBrowser::list()` call, which has no query string to have been parsed against the set. Itself capped at **1000**, because `?size=1000000` on a resource that cannot page is a request to materialise the table into PHP memory. | | `firefly.admin.data.exclude` | `''` | CSV of resource slugs to refuse. A **hard refusal, not a menu preference**: the resource is hidden *and* every operation on it is refused. Hiding `user` because the table holds PII achieves nothing if the row URL still answers. | | `firefly.admin.data.relations` | `true` | Discover relations, so records link to what they reference and the [entity map](admin.md#the-entity-map) has edges. Discovery **calls** the model methods that declare one — see [Relations](#relations) — so it is a key rather than a constant. | -The page-size cap is applied to whatever the caller asks for, so the query layer never sees a size it did not -agree to. +A size that arrives at `DataBrowser::list()` from application code is clamped into `[1, max-page-size]`, so +the query layer never sees a size it did not agree to. The listing page is stricter than that, and the rest +of this section is what it does instead. + +### Rows per page Rows per page on the `/firefly/data` listing is this browser's own `firefly.admin.data.page-size`; the dashboard-wide `firefly.admin.table.*` keys govern the [listing tables](admin.md) everywhere else, and **the @@ -435,6 +438,16 @@ read, so the rows-per-page control offers exactly the sizes this listing may ser by `max-page-size`, with `page-size` always among them — and a deployment that sets `FIREFLY_ADMIN_DATA_PAGE_SIZE=10` is offered ten rows rather than being unable to say where it is. +**That offered set is closed, not merely capped**, which is the one thing to know before reading a `?size=` +in a URL. The listing's query is parsed against it, and a size that is not a member is **refused** — the +listing falls back to `page-size` rather than being lowered to the nearest permitted value. So `?size=300` +renders 25 rows on the defaults above, not 200; and so does `?size=10`, because ten is below the shared +set's smallest member (`firefly.admin.table.page-sizes`, `25,50,100,200`) rather than above its ceiling. +A deployment that wants ten rows offered says so — `FIREFLY_ADMIN_TABLE_PAGE_SIZES=10,25,50,100,200`, or +`FIREFLY_ADMIN_DATA_PAGE_SIZE=10`, which forces its own default into the set. The reasoning is in +`Firefly\Admin\Table\TableSettings`: the honest answers to a hand-edited "give me 19 999 rows" are a cap or +a refusal, and a cap silently renders a page nobody asked for. + ## Reflection is confined to one class Discovery reads the compiled catalogue; schema derivation reads Laravel's schema builder; queries read the diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index f990c5de..3066abb8 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -2,7 +2,6 @@ @section('title', 'Browse data') @section('body') @php - use Firefly\Admin\Data\DataColumn; use Firefly\Admin\Format; $resource = $listing->resource; @@ -182,37 +181,30 @@ a fact about the resource DataSchema derived rather than a presentation choice this view makes, and which no other page has. --}} - {{-- The the rest of the dashboard gets from TableView, computed here - from the SCHEMA instead: this table's columns come from a resource, not from a - hand-written view model. A column whose values have a known maximum width takes - it and no more (the padding is added because box-sizing is border-box), and the - text and json columns share what is left — which is where a reader needs it. --}} + {{-- THE SAME COLGROUP THE SHARED `_table-head` EMITS, off the same TableView — see + AdminAction::dataTableView(), which maps each DataColumn onto the TableColumn + kind that already knows how wide it is, and widens the rigid ones until the + humanised header fits too. What this page cannot take from that partial is the + under it: the `t-` classes below are the DATABASE's types, a + fact about the resource DataSchema derived, and ColumnKind models presentation + kinds rather than types. So the widths are shared and the header row is not. --}} - @foreach ($columns as $column) - @php - // A STAMP IS NOT THIRTEEN CHARACTERS WIDE. Every rigid type here has a - // known maximum, but they do not share one: `2026-01-01 10:00:00` is - // NINETEEN, which is the number `TableColumn::stamp()` already carries - // for exactly this string, so the two mechanisms that size a timestamp - // on this dashboard agree instead of each guessing. Thirteen is what an - // int, a float and a boolean need, and at thirteen the stamp's own span - // measures 143px of text inside a 98px box — under `table-layout:fixed` - // the operator then reads `2026-01-01 10…` as the whole instant. - $width = match ($column->type) { - DataColumn::TYPE_DATETIME => 'calc(19ch + 2 * var(--row-x))', - DataColumn::TYPE_INT, DataColumn::TYPE_FLOAT, DataColumn::TYPE_BOOL => 'calc(13ch + 2 * var(--row-x))', - default => 'auto', - }; - @endphp - - @endforeach - @if ($identifier !== null)@endif + @foreach ($view->widths() as $width)@endforeach @foreach ($columns as $column) - {{-- sortable() returns column NAMES, not DataColumn objects. --}} - @endforeach - @if ($identifier !== null)@endif + @if ($identifier !== null)@endif diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 84a0d500..e640a893 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -440,16 +440,6 @@ table.datatable thead th.t-int,table.datatable thead th.t-float{text-align:right} table.datatable thead th.t-int a,table.datatable thead th.t-float a{justify-content:flex-end} table.datatable thead th a{display:inline-flex;align-items:center;gap:3px} - /* COLUMNS THAT CANNOT BE LONG SHOULD NOT BE WIDE. Auto-layout splits leftover width evenly, which - gave a one-digit `quantity` the same 200px as a timestamp and left the text columns cramped - between them. `width:1%` is the table idiom for "shrink to content": the numeric, boolean and - datetime columns take exactly what they need and the string and json columns absorb everything - left over, which is where a reader actually needs the room. */ - table.datatable td.t-int,table.datatable td.t-float,table.datatable td.t-bool,table.datatable td.t-datetime, - table.datatable thead th.t-int,table.datatable thead th.t-float,table.datatable thead th.t-bool,table.datatable thead th.t-datetime{ - width:1%;white-space:nowrap; - } - table.datatable td.t-string,table.datatable td.t-json{width:auto} table.datatable td.t-json{color:var(--ink-2)} table.datatable td.t-datetime{color:var(--ink-2);white-space:nowrap} table.datatable td.nil{text-align:center} diff --git a/packages/admin/src/Table/TableColumn.php b/packages/admin/src/Table/TableColumn.php index 411e0d21..f8e81425 100644 --- a/packages/admin/src/Table/TableColumn.php +++ b/packages/admin/src/Table/TableColumn.php @@ -21,6 +21,40 @@ */ final readonly class TableColumn { + /** + * WHAT ONE CHARACTER OF A HEADER COSTS, in the `ch` this class counts every other width in. + * + * None of the named constructors below needs this number, because each of their widths was tuned + * against a label an author wrote: somebody who types `Level` and `11` has already looked at both. A + * listing whose labels are DERIVED does need it. The data browser humanises a database column name — + * `failed_login_attempts` draws `Failed login attempts` — and nobody chose that column's name for its + * length, so nobody can have tuned a width to it. + * + * THE TWO FONTS ARE NOT THE SAME FONT, which is the whole reason this is a conversion and not a count. + * A ``'s `ch` is the 12.5px monospace advance the sheet declares on `table.ftable colgroup` + * (7.52px in Chromium), while `thead th` is 10px/700 uppercase in the UI face with `.12em` tracking. + * Measured across the labels a schema actually produces — `Id` 0.88, `Unit price` 0.95, + * `Failed login attempts` 0.99, `Created at` 1.02, `Warehouse manager` 1.12, `Customer` 1.14, + * `Amount` 1.20 — one header character costs between 0.88 and 1.20 of those `ch`, the top of the range + * being the short, round-lettered words rather than the long labels. 1.25 carries every one of them + * with margin. + * + * IT IS AN ESTIMATE, AND THE HEADER THAT USES IT SAYS SO. PHP cannot measure a font, so a pathological + * label — twelve `W`s measures 1.52 — still overflows its column; the data browser therefore puts the + * full label on the `` under `table-layout:fixed`. +Rigid kinds are sized from their own alphabet (seven and a half characters is `DELETE` with room), and the +rest share what is left in proportion to how much a reader needs. + +!!! danger "A `` width includes the cell padding, and that is where `DELETE` went" + `box-sizing:border-box` applies to table columns like everything else, so `width:7.5ch` on a padded + cell is seven and a half characters **minus** both paddings — about 34px of content on the dashboard's + own 14px padding. The verb clipped, on the Routes page, which is the page this whole area was rebuilt + from. Every rigid width is emitted as `calc(ch + 2 * var(--row-x))`, and `--row-x` comes from + `density` so the arithmetic follows the configuration. + +**A path and a qualified name elide in opposite directions.** `/api/v1/orgs/{o}/workspaces` and +`App\Http\Controllers\Api\V1\WorkspaceController` are the same length in the same font and they are +discriminated at opposite ends: a path by its head, so it clips at the end; a qualified name by its leaf, so +it is drawn on two lines with the short name on top and it is the *prefix* that may go. The qualified kind +takes its separator as a parameter, which is why a dotted config key, a meter name and a class name all get +the same cell instead of three mechanisms. + +!!! note "`overflow-wrap: anywhere` is what collapsed the Path column" + Per CSS Text 3, `anywhere` and `break-word` both break an unbreakable token at render time, but + `anywhere` also **contributes its break opportunities to min-content sizing**. Under auto layout — which + is what every table here had, because none of them declared a `table-layout` — a path's minimum width + therefore became one glyph, the engine gave it three characters, and `/greetings/{name}` rendered as six + stacked lines beside 1100px of empty column. With seven routes on the page. + +**The header stays put.** `thead th` has carried `position:sticky` for as long as this dashboard has +existed and it had never once worked: the wrapper it sticks inside had `overflow-x:auto` and no height, so +it never scrolled — `main` did — and a sticky element does not follow an ancestor's scrollport. Giving the +wrapper `max-height: var(--table-vh)` is the whole fix. The rule under the header is an inset box-shadow +rather than a border, because under `border-collapse:collapse` a border belongs to the table's border grid +and stays behind with the rows. + +**Paging, sorting and searching happen on the server.** They used to happen in the browser: the complete +list was rendered into every response and a keyup handler hid rows, which is fine for eleven rows, is a +janky filter and a large response at two hundred, and produces a "12 of 207" readout that is a statement +about the DOM rather than about the application. Each listing now reads `page`, `size`, `sort`, `dir` and +`q` from the URL with every bound applied — the page clamped and never redirected, the size a member of the +offered set, the sort column one the page actually draws, the term trimmed and length-capped — and every +link on the page is rebuilt from those parsed values rather than concatenated from carried strings. + +Two listings on one page (Conditions, Config properties) each take a **qualifier**, so their parameters are +`pos_page` and `neg_page` — the same shape Spring gives a controller that resolves two `Pageable`s with +`@Qualifier` — and each carries the other's position, so paging one never silently resets the other. + +!!! danger "A sort with no tiebreak is a listing that loses rows" + Ordering by a column with duplicate values leaves the tied rows in whatever order the source finds + convenient, and it is free to find a different one convenient for the query behind page 1 and the query + behind page 2: a row is then shown on both pages and another on neither, and the reader sees a table + that is missing records which are really there. Every listing here appends a second, **always + ascending** key over something unique. Ascending even under a descending sort, because it is an identity + rather than a second ordering. + +Because all of it lives in the URL, it composes with the ten-second **Auto** refresh for free: the refresh +is a full `window.location.reload()`, so a reader on page 7 of a sorted, searched listing comes back to +page 7 of the same listing. The only thing a reload cannot restore is the scroll position *inside* a table, +now that tables scroll in their own box, so that is saved per URL in `sessionStorage` and restored on reload or back/forward navigation when +`firefly.admin.table.remember-scroll` is enabled. Ordinary navigation starts at the top. + ## The datasource page Four questions an operator asks at 3am that this dashboard could not answer: @@ -401,6 +473,13 @@ container image, and a web form that edits the file holding your database passwo [`HealthDetailsAuthorizer`](actuator.md#who-may-read-the-component-details); the dashboard never consults it, because its URL is already the whole security boundary. +- **A listing's search box searches what the page DRAWS, not the record behind it.** A column the table does + not show is not searched, because a search that matched on a hidden value would answer a question about + something the reader cannot see. Add the column to see it. +- **`?page=` past the end of a SQL-backed listing costs one extra query.** The in-memory listings know the + total before they slice and clamp for free; the data browser does not, so it fetches the last page after + finding the requested one empty. Only a hand-edited URL or a stale bookmark reaches it. + --- See also: [Actuator](actuator.md) for the endpoints themselves, [Observability](observability.md) for the metrics diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index 06ea2f85..d87917e7 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -293,6 +293,24 @@ ticket or hand to someone else, which is most of what a data explorer is for — the whole state, because a sort that dropped the filter would widen the listing back to every row, which reads as rows appearing from nowhere. +Every link is rebuilt from the parsed query rather than concatenated: the four `$keep…` strings the listing +view used to glue onto every href — with a comment beside them asking the next author not to forget one — +are gone, and a sort that dropped the filter is no longer expressible. The rows-per-page control gained a +submit button, which it had never had: it was a `
    + {{-- sortable() returns column NAMES, not DataColumn objects. + + `title` BECAUSE THE WIDTH THAT HOLDS THIS LABEL IS AN ESTIMATE. PHP + cannot measure a font, so the column was widened from a character + count (TableColumn::HEADER_CH_PER_CHARACTER) and a label of unusually + wide glyphs can still outrun it — at which point `overflow:hidden` + takes the tail off in silence. The full label on hover is what `_cell` + already gives a clipped value. `scope="col"` is the partial's, and a + header that announces itself to a screen reader on one listing should + do it on all of them. --}} + @if (in_array($column->name, $schema->sortable(), true)) {{ $column->label() }}{{ $query->indicator($column->name) }} @@ -222,7 +214,7 @@ @endif
    `'s `title`, exactly as `_cell` already does for a value it clips. + */ + public const float HEADER_CH_PER_CHARACTER = 1.25; + + /** + * What the ordering indicator adds to a SORTABLE header, in the same `ch`. + * + * `th .ord` is a 10px-wide inline-block inside an `inline-flex` anchor with `gap:3px`, and that width + * is declared rather than taken from the arrow — so the 13px is paid whether the column is the sorted + * one or not, which is what stops a header changing width under the click that sorts it. 13px over a + * 7.52px `ch` is 1.73, rounded up. + */ + public const float SORT_INDICATOR_CH = 1.75; + private function __construct( public string $key, public string $label, @@ -88,6 +122,37 @@ public static function line(string $key, string $label, float $weight = 4, bool return new self($key, $label, ColumnKind::Line, $weight, $sortable, ''); } + /** + * The same column, widened until its own HEADER fits beside its values. + * + * A rigid width is sized from the alphabet the CELLS can hold — nineteen characters for an ISO instant, + * seven and a half for the verb `DELETE` — and on a listing with hand-written labels that is the whole + * story, because the author picked the label against the width. It stops being the whole story the + * moment the label is derived. `thead th` is `white-space:nowrap` and `table.ftable td,th` is + * `overflow:hidden`, so a header wider than its column is cut mid-glyph; and under the + * `table-layout:fixed` this system runs on, the column cannot grow to rescue it the way the + * `table-layout:auto` the data browser used to run on did. So the width becomes the greater of the two + * needs, never the lesser — a column still holds its values, and now it also says what they are. + * + * A FLEXIBLE COLUMN IS RETURNED UNTOUCHED, because its `$width` is a weight and not a character count. + * Widening it would not widen a column; it would enlarge that column's share of the leftovers at its + * neighbours' expense, for a reason that has nothing to do with them — and a percentage column on a + * table this wide clears any header it is likely to be given anyway. + */ + public function fittingItsHeader(): self + { + if (! $this->isRigid()) { + return $this; + } + + $header = mb_strlen($this->label) * self::HEADER_CH_PER_CHARACTER + + ($this->isSortable() ? self::SORT_INDICATOR_CH : 0.0); + + return $header <= $this->width + ? $this + : new self($this->key, $this->label, $this->kind, $header, $this->sortable, $this->separator); + } + public function cssClass(): string { return $this->kind->cssClass(); diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 5e859220..304052d1 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -11,7 +11,9 @@ use Firefly\Admin\BeanGraph; use Firefly\Admin\Data\ConnectionWizard; use Firefly\Admin\Data\DataBrowser; +use Firefly\Admin\Data\DataColumn; use Firefly\Admin\Data\DataFilter; +use Firefly\Admin\Data\DataListing; use Firefly\Admin\Data\DataMap; use Firefly\Admin\Data\DatasourceReport; use Firefly\Admin\Format; @@ -385,6 +387,7 @@ private function dataPage(Request $request): SymfonyResponse return $this->html($this->render('data-list', [ 'listing' => $listing, 'query' => $query, + 'view' => $this->dataTableView($listing), 'slice' => ListingPage::sliced($listing->rows, $listing->total, $query), 'writable' => $this->data->isWritable(), 'relations' => $this->data->relationsFor($slug), @@ -392,6 +395,66 @@ private function dataPage(Request $request): SymfonyResponse ]), 200); } + /** + * A listing of a browsable resource as a TableView — the same object the other twelve listings on this + * dashboard build, assembled from a resource's SCHEMA instead of from a hand-written column list. + * + * WHY THIS EXISTS RATHER THAN A SECOND COLGROUP. `data-list.blade.php` used to write its own width + * expressions as literal strings: `calc(19ch + 2 * var(--row-x))` for a datetime, thirteen for the + * numeric kinds, `auto` for the rest. The nineteen was a COPY of `TableColumn::stamp()`'s default, + * under a comment claiming that the two mechanisms sizing a timestamp on this dashboard agreed — which + * nothing enforced, no test related, and a change to `stamp()` would have quietly falsified. It is that + * default now, so there is one number and moving it moves both. What the view keeps is the + * `t-` class on each cell: those are the DATABASE's types — int, float, bool, datetime, string, + * json — a fact about the resource DataSchema derived, and ColumnKind models presentation kinds rather + * than types. So the widths are shared and the header row is not. + * + * AND THE RIGID WIDTHS ARE FITTED TO THE HEADERS HERE, which no other listing needs. Every other page's + * labels are hand-written beside the width chosen to hold them; this one's are humanised from a column + * name nobody picked for its length, so `failed_login_attempts` draws a 21-character header into a + * column sized for a five-figure count and `overflow:hidden` takes the rest. See + * TableColumn::fittingItsHeader(). + */ + private function dataTableView(DataListing $listing): TableView + { + $sortable = $listing->schema?->sortable() ?? []; + + $columns = array_map( + static function (DataColumn $column) use ($sortable): TableColumn { + $key = $column->name; + $label = $column->label(); + $orderable = in_array($key, $sortable, true); + + return match ($column->type) { + DataColumn::TYPE_DATETIME => TableColumn::stamp($key, $label, sortable: $orderable), + // THIRTEEN, WHERE A HAND-WRITTEN `number()` TAKES NINE. Nine characters is a + // five-figure count with its thousands separator, which is what a dashboard column an + // author chose holds. This one holds whatever its type admits: a bigint key, a + // `decimal(12,2)` total that arrives as the string `1234567.89`, or the word `false`. + DataColumn::TYPE_INT, DataColumn::TYPE_FLOAT, DataColumn::TYPE_BOOL => TableColumn::number($key, $label, ch: 13, sortable: $orderable), + // Text and json take a SHARE of what the rigid columns leave, which is where a reader + // of a data browser needs the room — and an equal share only because a schema gives no + // ground to prefer one text column over another. + default => TableColumn::text($key, $label, sortable: $orderable), + }; + }, + $listing->columns(), + ); + + $columns = array_map( + static fn (TableColumn $column): TableColumn => $column->fittingItsHeader(), + $columns, + ); + + if ($listing->schema?->identifierColumn() !== null) { + // NINE, WHERE `actions()` DEFAULTS TO ELEVEN: eleven is a column holding a form with a button, + // and this one holds a single `Open →` link. Its header is empty, so nothing fits it to. + $columns[] = TableColumn::actions(ch: 9); + } + + return TableView::of(...$columns); + } + /** * The filters a listing URL carries, in either spelling. * diff --git a/packages/admin/tests/Data/Fixtures/AdminSignIn.php b/packages/admin/tests/Data/Fixtures/AdminSignIn.php new file mode 100644 index 00000000..193b8676 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminSignIn.php @@ -0,0 +1,32 @@ + + */ +final class AdminSignInRepository extends EloquentRepository +{ + protected string $model = AdminSignIn::class; +} diff --git a/packages/admin/tests/DataBrowserPageTest.php b/packages/admin/tests/DataBrowserPageTest.php index b84d3cb7..caf636c8 100644 --- a/packages/admin/tests/DataBrowserPageTest.php +++ b/packages/admin/tests/DataBrowserPageTest.php @@ -179,6 +179,45 @@ ->and($html)->toContain('2026-01-01 10:00:00'); }); +/** + * A RIGID COLUMN IS SIZED FOR ITS HEADER TOO, AND ON THIS PAGE ONLY THIS PAGE NEEDS THAT. + * + * Every other listing on the dashboard writes its own labels beside the widths that hold them — `Level` at + * `ch: 11`, `Default` at `ch: 10.5` — so the type's width is the whole answer. Here the label is humanised + * from a column name nobody chose for its length: `failed_login_attempts` draws `Failed login attempts`, + * twenty-one characters of 10px uppercase header with `.12em` tracking, into a column sized at thirteen for + * a five-figure count. Measured in Chromium against these exact rules, that header renders 184px inside a + * 126px box and reads `FAILED LOGIN ATTE`, cut mid-glyph — `table.ftable td,th` is `overflow:hidden` and + * carries no ellipsis, so nothing on the page says it was cut. Under the `table-layout:auto` this wave + * replaced the column simply grew to fit, which is why this is a regression and not a pre-existing gap. + * + * THE NUMBERS ARE THE ARITHMETIC, NOT A ROUND-UP. 21 characters × 1.25 `ch` + 1.75 for the sortable + * header's ordering indicator is 28; 17 × 1.25 + 1.75 is 23, which is wider than the nineteen an ISO + * instant needs. `Id` at 2 characters stays at thirteen, because fitting a header never NARROWS a column + * below the alphabet its values can hold. See TableColumn::fittingItsHeader(). + */ +it('widens a rigid column when its humanised header is wider than its values', function () { + /** @var DataBrowserTestCase $this */ + $this->exposeAdminSignIns(); + + $html = (string) $this->get('/firefly/data?resource=admin-sign-in')->assertStatus(200)->getContent(); + + expect($html) + ->toContain('calc(28ch + 2 * var(--row-x))') + ->toContain('calc(23ch + 2 * var(--row-x))') + // `Id` is short, so its column keeps the width an int column needs and gains nothing. + ->toContain('calc(13ch + 2 * var(--row-x))') + // Neither long column is left at its type's width. + ->not->toContain('calc(19ch + 2 * var(--row-x))') + // The header really is the long one, and it is recoverable in full when the estimate falls short. + ->toContain('title="Failed login attempts"') + ->toContain(''); + + // Exactly one rigid column stayed at thirteen: `id`. Two were widened, and the actions column is 9. + expect(substr_count($html, 'calc(13ch + 2 * var(--row-x))'))->toBe(1) + ->and(substr_count($html, 'calc(9ch + 2 * var(--row-x))'))->toBe(1); +}); + /** * ROWS PER PAGE HERE IS THE BROWSER'S OWN KEY, and it stopped being that the moment this page started * stating a size on every call: `DataBrowser::list()` was handed `$query->size` — the dashboard-wide diff --git a/packages/admin/tests/Support/DataBrowserTestCase.php b/packages/admin/tests/Support/DataBrowserTestCase.php index f552b864..741bf5f1 100644 --- a/packages/admin/tests/Support/DataBrowserTestCase.php +++ b/packages/admin/tests/Support/DataBrowserTestCase.php @@ -7,6 +7,7 @@ use Firefly\Actuator\Introspection\BeansCatalog; use Firefly\Admin\Tests\Data\Fixtures\AdminLinkRepository; use Firefly\Admin\Tests\Data\Fixtures\AdminRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\AdminSignInRepository; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\DB; @@ -96,6 +97,36 @@ public function exposeAdminLinks(): void $this->exposeRepository(AdminLinkRepository::class); } + /** + * The same again, for a resource whose COLUMN NAMES ARE LONGER THAN ITS VALUES — the shape of any audit + * or sign-in table, and the one shape a hand-written dashboard listing never has. + * + * `admin_sign_ins` holds two digits and an instant under the headers `Failed login attempts` and + * `Last signed in at`, which `DataColumn::label()` humanised from the column names. A rigid column sized + * from its TYPE alone — thirteen characters for a count, nineteen for a timestamp — cannot hold either + * of them, and under `table-layout:fixed` it cannot grow to try. See the width assertion in + * DataBrowserPageTest. + * + * Public for the same reason its siblings are: Pest binds the closure's `$this` to a class PHPStan + * cannot relate to this one. + */ + public function exposeAdminSignIns(): void + { + Schema::create('admin_sign_ins', function (Blueprint $table): void { + $table->increments('id'); + $table->string('email'); + $table->integer('failed_login_attempts'); + $table->dateTime('last_signed_in_at')->nullable(); + }); + + DB::table('admin_sign_ins')->insert([ + ['id' => 1, 'email' => 'ada@example.test', 'failed_login_attempts' => 0, 'last_signed_in_at' => '2026-01-01 10:00:00'], + ['id' => 2, 'email' => 'grace@example.test', 'failed_login_attempts' => 12, 'last_signed_in_at' => null], + ]); + + $this->exposeRepository(AdminSignInRepository::class); + } + /** * The actuator's boot-time bean snapshot, swapped for one naming a single repository — done BEFORE the * first request, which is when the dashboard assembles its DataBrowser and reads the catalogue. diff --git a/packages/admin/tests/Table/TableViewTest.php b/packages/admin/tests/Table/TableViewTest.php index b23fd83d..8ef68731 100644 --- a/packages/admin/tests/Table/TableViewTest.php +++ b/packages/admin/tests/Table/TableViewTest.php @@ -127,3 +127,48 @@ ->and(TableColumn::qualified('key', 'Key', separator: '.')->separator)->toBe('.') ->and(TableColumn::qualified('client', 'Client', separator: '')->separator)->toBe(''); }); + +/** + * FITTING A HEADER ONLY EVER WIDENS. A rigid width is the alphabet the CELLS can hold, so taking the + * greater of the two needs is the only composition that keeps both promises: `Id` over a bigint column + * stays at the thirteen characters the values want, and `Failed login attempts` over the same column takes + * the twenty-eight its header wants. 21 characters × 1.25 `ch` + 1.75 for the ordering indicator is 28. + */ +it('widens a rigid column to its own header and never narrows one', function () { + $short = TableColumn::number('id', 'Id', ch: 13)->fittingItsHeader(); + $long = TableColumn::number('failed_login_attempts', 'Failed login attempts', ch: 13)->fittingItsHeader(); + + expect($short->width)->toBe(13.0) + ->and($long->width)->toBe(28.0) + // Everything else about the column survives the widening. + ->and($long->key)->toBe('failed_login_attempts') + ->and($long->label)->toBe('Failed login attempts') + ->and($long->kind)->toBe(ColumnKind::Number) + ->and($long->isSortable())->toBeTrue(); +}); + +// The indicator is a declared 10px box plus the anchor's 3px gap, paid whether the column is the sorted +// one or not — so an UNSORTABLE header of the same length needs 1.75ch less, and says so. +it('charges a sortable header for the ordering indicator and an unsortable one for nothing', function () { + expect(TableColumn::number('n', 'Failed login attempts', ch: 13)->fittingItsHeader()->width)->toBe(28.0) + ->and(TableColumn::number('n', 'Failed login attempts', ch: 13, sortable: false)->fittingItsHeader()->width)->toBe(26.25); +}); + +/** + * A FLEXIBLE COLUMN'S `$width` IS A WEIGHT, so fitting it to a header would not widen a column — it would + * enlarge that column's share of the leftovers at its neighbours' expense, for a reason that has nothing to + * do with them. `Implements` at weight 3 would have become weight 12.5 and swallowed the table. + */ +it('leaves a flexible column alone, because its width is a weight and not a character count', function () { + $text = TableColumn::text('interfaces', 'Implements', weight: 3); + + expect($text->fittingItsHeader())->toBe($text) + ->and(TableColumn::path('path', 'Path', weight: 5)->fittingItsHeader()->width)->toBe(5.0); +}); + +// The stamp's nineteen characters hold `2026-09-22 20:49:26` and a header of up to fourteen; past that the +// header decides, which is the case the data browser's `Last signed in at` lands in. +it('lets a long header outgrow the alphabet a timestamp needs', function () { + expect(TableColumn::stamp('created_at', 'Created at')->fittingItsHeader()->width)->toBe(19.0) + ->and(TableColumn::stamp('last_signed_in_at', 'Last signed in at')->fittingItsHeader()->width)->toBe(23.0); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 87f534d1..a6078ae5 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1201,11 +1201,16 @@ // */ // 'writable' => env('FIREFLY_ADMIN_DATA_WRITABLE', false), // - // // Rows per page on the /firefly/data listing, and the ceiling a `?size=` in the URL may - // // reach. Both are clamped to a hard maximum of 1000 so no query string can ask for the whole - // // table at once. They compose with the dashboard-wide `firefly.admin.table.*` keys above: - // // the rows-per-page control offers that shared set narrowed by `max-page-size`, with - // // `page-size` always among the sizes it offers. Defaults: 25 and 200. + // // Rows per page on the /firefly/data listing, and the ceiling that narrows the set of sizes + // // it offers. Both are clamped to a hard maximum of 1000 so no query string can ask for the + // // whole table at once. They compose with the dashboard-wide `firefly.admin.table.*` keys + // // above: the rows-per-page control offers that shared set narrowed by `max-page-size`, with + // // `page-size` always among the sizes it offers. The offered set is CLOSED — a `?size=` that + // // is not one of its members is refused and the listing falls back to `page-size`, rather + // // than being lowered to the ceiling — so a deployment that wants a size on offer adds it to + // // `firefly.admin.table.page-sizes` or names it here. `max-page-size` is a plain cap only for + // // a direct Firefly\Admin\Data\DataBrowser::list() call, which is parsed against no query + // // string. Defaults: 25 and 200. // 'page-size' => 25, // 'max-page-size' => 200, // diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php index a00b31ad..3011dd35 100644 --- a/tests/Browser/AdminTablesTest.php +++ b/tests/Browser/AdminTablesTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Admin\Table\TableColumn; use Firefly\Tests\Browser\Support\AdminDashboardBrowserTestCase; pest()->extend(AdminDashboardBrowserTestCase::class); @@ -473,3 +474,78 @@ ->assertNoJavaScriptErrors() ->screenshot(filename: 'admin-data-datetime-column'); }); + +/** + * THE HEADERS OF THE ONE LISTING WHOSE LABELS NOBODY WROTE, measured. + * + * Every other listing on this dashboard has hand-written labels beside hand-tuned widths, and the wave has + * a browser test per page saying so — the verb `DELETE`, the widest level name, the `Default` chip. The + * data browser humanises a database column name instead, so `failed_login_attempts` draws + * `Failed login attempts` and no author ever saw it; sized from the column TYPE alone that header rendered + * 184px inside a 126px box and read `FAILED LOGIN ATTE`, cut mid-glyph with nothing saying it was cut. + * + * THE SECOND HALF IS THE INTERESTING HALF. `TableColumn::fittingItsHeader()` widens such a column from a + * CHARACTER COUNT, because PHP cannot measure a font — so the two constants it counts with are a claim + * about this stylesheet and this face, and a claim is worth exactly the measurement behind it. The + * inequality below is the whole claim: a label's drawn width plus the 13px the ordering indicator occupies + * must fit inside the `ch` budget PHP grants it. The labels are the ones a schema really produces, + * including the dearest per character (`Amount`, which clears its budget by under 4%) and the longest. + */ +it('fits a humanised header inside the width PHP computed for it', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + $this->seedOrders(); + + $perCharacter = TableColumn::HEADER_CH_PER_CHARACTER; + $indicator = TableColumn::SORT_INDICATOR_CH; + + visit('/firefly/data?resource=order-entity') + ->assertScript(<< { + const table = document.querySelector('table.datatable'); + if (table === null) { return 'no data-browser listing on the page'; } + + // What the page really drew: a is `white-space:nowrap` inside `overflow:hidden`, so + // any overflow at all is a header cut mid-glyph. + for (const th of table.querySelectorAll('thead th')) { + const over = th.scrollWidth - th.clientWidth; + if (over > 0) { + return 'the header ' + JSON.stringify(th.textContent.trim()) + ' is clipped by ' + over + 'px'; + } + } + + // One colgroup `ch` in pixels, out of the font the sheet declares on the colgroup — the + // unit every number in TableColumn is counted in. + const probe = document.createElement('span'); + document.body.appendChild(probe); + probe.style.cssText = 'position:absolute;visibility:hidden;width:1ch;font:' + + getComputedStyle(table.querySelector('colgroup')).font; + const ch = probe.getBoundingClientRect().width; + + // What the ordering indicator occupies: a 10px inline-block plus the anchor's 3px gap, + // declared in px and paid whether the column is the sorted one or not. + const anchor = table.querySelector('thead th a'); + const header = getComputedStyle(table.querySelector('thead th')); + probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:' + header.font + + ';letter-spacing:' + header.letterSpacing + ';text-transform:' + header.textTransform; + probe.textContent = anchor.childNodes[0].textContent.trim(); + const ornament = anchor.getBoundingClientRect().width - probe.getBoundingClientRect().width; + + for (const label of ['Id', 'Meta', 'Total', 'Amount', 'Active', 'Ship to', 'Customer', + 'Quantity', 'Order id', 'Unit price', 'Created at', 'Total amount', + 'Subscription id', 'Warehouse manager', 'Failed login attempts', + 'Two factor recovery codes']) { + probe.textContent = label; + const needed = probe.getBoundingClientRect().width + ornament; + const budget = (label.length * {$perCharacter} + {$indicator}) * ch; + if (needed > budget) { + return 'PHP budgets ' + budget.toFixed(1) + 'px for the header ' + JSON.stringify(label) + + ' and it draws ' + needed.toFixed(1) + 'px (indicator ' + ornament.toFixed(1) + + 'px, 1ch = ' + ch.toFixed(2) + 'px)'; + } + } + + return true; + })() + JS, true) + ->assertNoJavaScriptErrors(); +}); From 280c46f73e3c2fb5b6c3e0e5a619e8653b4e057e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 21:56:20 -0700 Subject: [PATCH 57/78] fix(web): guard the problem type-uri and bring every published document up to the RFC 9457 shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorPageSettings::$typeUri was the one configuration-supplied URI in the class that reached its consumer verbatim: Config::string() does not trim, ProblemType::of() compares the base to its 'about:blank' and '' sentinels with ===, and the value is published in the member RFC 9457 §3.1.1 defines as dereferenceable and every API console renders as a link. A base of " about:blank" published " about:blank/resource-not-found", a Helm block scalar's trailing newline published a type with a newline inside it, and "javascript:alert(document.cookie)" reached the wire intact. It is now assigned through self::typeUri() in the constructor, beside the three self::url() assignments, so the guarantee holds for a settings object built by hand as well as one read from configuration: the edges are trimmed, an interior tab/LF/CR is refused, and only '', 'about:blank' or an absolute http(s) base is kept — anything else falls back to the documented default rather than to '', which is a position an operator takes deliberately. The RFC 9457 conformance pass also left the documentation stating the behaviour it inverted. Both book editions taught the old, un-slashed `instance` as a deliberate lesson one paragraph below the listing the pass had just edited; ten published samples across the book and the tutorial showed a document the framework can no longer produce, none of them carrying the `type` member that is now present by default. Both paragraphs are rewritten around §3.1.5, and every sample gains its leading slash and its type. docs/modules/error-handling.md is left alone on purpose: its JSON is the output of the ErrorResponse::fromException() listing printed above it, and fromException() sets no type. A new guard in tests/DocsProseIsRealTest.php derives the reference from ProblemMapper::instanceFor() and the member order from a real ErrorResponse::toArray(), then holds every "instance" in every fenced block on every prose page to both, and refuses a paragraph that explains the member as $request->path() without naming what replaced it. ProblemMapper::instanceFor()'s docblock no longer claims the published document is unfixed. --- book/src-es/04-first-http-api.md | 14 +- book/src/04-first-http-api.md | 14 +- docs/tutorial.es.md | 3 +- docs/tutorial.md | 3 +- packages/web/src/Error/ErrorPageSettings.php | 70 +++++++++- packages/web/src/Error/ProblemMapper.php | 15 ++- .../web/tests/Error/ErrorPageSecurityTest.php | 70 ++++++++++ skeleton/config/firefly.php | 10 ++ tests/DocsProseIsRealTest.php | 121 ++++++++++++++++++ 9 files changed, 300 insertions(+), 20 deletions(-) diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index 6fb9e0c7..cee274b2 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -503,7 +503,7 @@ Cinco brazos que cubren cuatro casos — el `405` tiene un brazo propio solo par Una `FireflyException` — o una de sus subclases tipadas, como `ResourceNotFoundException` — se devuelve intacta y se renderiza con su propio `httpStatus()`, porque su mensaje lo escribió tu aplicación *para* el cliente; ese es justamente el sentido de la taxonomía. El propio límite de tiempo de ejecución de PHP se nombra aparte y responde `503` con una cabecera `Retry-After`, porque «el servidor detuvo esta petición a los N segundos» es algo sobre lo que quien llama puede actuar y un `500` desnudo no lo es. Una excepción HTTP de Laravel/Symfony — una URL que no coincide con ninguna ruta, un verbo que una ruta no acepta — conserva su **código de estado real**, así que una ruta no coincidente sigue respondiendo `404` y nunca un `500` engañoso; solo se reemplaza la redacción del propio enrutador por una frase escrita para una persona, y los verbos permitidos de un `405` pasan a un miembro `allowed` y a la cabecera `Allow`, donde un cliente puede leerlos sin analizar inglés. Cualquier otra cosa es un accidente, y `$disclose` — `firefly.web.problem.disclose`, por defecto `false` — decide si su mensaje puede publicarse siquiera: con él apagado el cuerpo lleva una frase fija que nombra la misma referencia que lleva el `traceId` del documento, y el mensaje real se queda en el log, que es donde el SQL de una `QueryException` y sus enlaces deben estar. -Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: es `$request->path()`, que Laravel devuelve **sin** barra inicial, así que es `api/v1/wallets/wlt-999` y no `/api/v1/wallets/wlt-999` — una cosa pequeña, y exactamente el tipo de cosa pequeña que un cliente que compara cadenas hace mal. +Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: el RFC 9457 §3.1.5 lo define como una **referencia** URI, y una referencia relativa se resuelve contra la URI base del documento — así que el `api/v1/wallets/wlt-999` pelado que devuelve `$request->path()`, servido desde `/api/v1/wallets/wlt-999`, identificaría `/api/v1/api/v1/wallets/wlt-999`. LaraFly publica la forma relativa a la raíz, que es el único trabajo de `ProblemMapper::instanceFor()`, y un cliente puede compararla con la ruta que pidió. Fíjate también en `type`: el RFC 9457 §3.1.1 dice que un `type` ausente *es* `about:blank`, y LaraFly lo escribe en vez de dejar que el lector tenga que saberlo — apunta `firefly.web.problem.type-uri` a una URI base y el `code` estable deriva uno real y abrible. ```json { @@ -513,7 +513,8 @@ Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: "category": "business", "severity": "warning", "detail": "Wallet wlt-999 not found", - "instance": "api/v1/wallets/wlt-999", + "type": "about:blank", + "instance": "/api/v1/wallets/wlt-999", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00" @@ -530,7 +531,8 @@ Un fallo de comprobación `#[Valid]` en `POST /api/v1/wallets` — un `owner_id` "category": "validation", "severity": "warning", "detail": "Validation failed", - "instance": "api/v1/wallets", + "type": "about:blank", + "instance": "/api/v1/wallets", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00", @@ -552,7 +554,8 @@ Un intento de retiro se rechaza por dos vías distintas, y las dos **no dan el m "category": "security", "severity": "warning", "detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: Authentication is required.", - "instance": "api/v1/wallets/wlt-1/withdraw", + "type": "about:blank", + "instance": "/api/v1/wallets/wlt-1/withdraw", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00" @@ -569,7 +572,8 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun "category": "security", "severity": "warning", "detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: You do not have permission to do this.", - "instance": "api/v1/wallets/wlt-1/withdraw", + "type": "about:blank", + "instance": "/api/v1/wallets/wlt-1/withdraw", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00", diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md index af0b367d..9e4d6dbd 100644 --- a/book/src/04-first-http-api.md +++ b/book/src/04-first-http-api.md @@ -503,7 +503,7 @@ Five arms covering four cases — the `405` has an arm of its own only so the ve A `FireflyException` — or one of its typed subclasses, like `ResourceNotFoundException` — is returned untouched and renders at its own `httpStatus()`, because its message was written by your application *for* the client; that is the whole point of the taxonomy. PHP's own execution-time limit is named separately and answers `503` with a `Retry-After` header, because "the server stopped this request after N seconds" is something a caller can act on and a bare `500` is not. A Laravel/Symfony HTTP exception — a URL matching no route, a verb a route does not accept — keeps its **real status code**, so an unmatched route still answers `404` and never a misleading `500`; only the router's own wording is replaced with a sentence written for a person, and a `405`'s permitted verbs move into an `allowed` member and the `Allow` header where a client can read them without parsing English. Anything else is an accident, and `$disclose` — `firefly.web.problem.disclose`, default `false` — decides whether its message may be published at all: with it off the body carries a fixed sentence naming the same reference the document's `traceId` carries, and the real message stays in the log, which is where a `QueryException`'s SQL and its bindings belong. -Requesting a wallet that was never opened renders like this. Note `instance`: it is `$request->path()`, which Laravel returns **without** a leading slash, so it is `api/v1/wallets/wlt-999` and not `/api/v1/wallets/wlt-999` — a small thing, and exactly the kind of small thing a client that compares strings gets wrong. +Requesting a wallet that was never opened renders like this. Note `instance`: RFC 9457 §3.1.5 makes it a URI **reference**, and a relative reference resolves against the document's base URI — so the bare `api/v1/wallets/wlt-999` that `$request->path()` answers, served from `/api/v1/wallets/wlt-999`, would identify `/api/v1/api/v1/wallets/wlt-999`. LaraFly publishes the root-relative form, `ProblemMapper::instanceFor()`'s one job, and a client may compare it to the path it asked for. Note `type` too: RFC 9457 §3.1.1 says an absent `type` *is* `about:blank`, and LaraFly writes it out rather than leaving the reader to know that — point `firefly.web.problem.type-uri` at a base URI instead and the stable `code` derives a real, openable one. ```json { @@ -513,7 +513,8 @@ Requesting a wallet that was never opened renders like this. Note `instance`: it "category": "business", "severity": "warning", "detail": "Wallet wlt-999 not found", - "instance": "api/v1/wallets/wlt-999", + "type": "about:blank", + "instance": "/api/v1/wallets/wlt-999", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00" @@ -530,7 +531,8 @@ A failed `#[Valid]` check on `POST /api/v1/wallets` — an empty `owner_id` — "category": "validation", "severity": "warning", "detail": "Validation failed", - "instance": "api/v1/wallets", + "type": "about:blank", + "instance": "/api/v1/wallets", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00", @@ -552,7 +554,8 @@ A withdraw attempt is refused twice over, and the two refusals are **not the sam "category": "security", "severity": "warning", "detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: Authentication is required.", - "instance": "api/v1/wallets/wlt-1/withdraw", + "type": "about:blank", + "instance": "/api/v1/wallets/wlt-1/withdraw", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00" @@ -569,7 +572,8 @@ A withdraw attempt is refused twice over, and the two refusals are **not the sam "category": "security", "severity": "warning", "detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: You do not have permission to do this.", - "instance": "api/v1/wallets/wlt-1/withdraw", + "type": "about:blank", + "instance": "/api/v1/wallets/wlt-1/withdraw", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7", "timestamp": "2026-06-07T10:30:00+00:00", diff --git a/docs/tutorial.es.md b/docs/tutorial.es.md index 7df28cb5..da2c51ca 100644 --- a/docs/tutorial.es.md +++ b/docs/tutorial.es.md @@ -696,7 +696,8 @@ Content-Type: application/problem+json "category": "business", "severity": "warning", "detail": "No saved greeting for 'Nowhere'", - "instance": "greetings/Nowhere/record" + "type": "about:blank", + "instance": "/greetings/Nowhere/record" } ``` diff --git a/docs/tutorial.md b/docs/tutorial.md index c66be46c..75700dbf 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -686,7 +686,8 @@ Content-Type: application/problem+json "category": "business", "severity": "warning", "detail": "No saved greeting for 'Nowhere'", - "instance": "greetings/Nowhere/record" + "type": "about:blank", + "instance": "/greetings/Nowhere/record" } ``` diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 13f28ae1..98036562 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -110,6 +110,27 @@ public string $support; + /** + * THE FOURTH OPERATOR-SUPPLIED URI, AND THE ONLY ONE THAT NEVER REACHES AN `href`. + * + * RFC 9457 §3.1.1's `type` is a URI that identifies the KIND of problem, and the RFC's own words for it + * are "dereferenceable" — the member exists so that a person can open it. That is what makes it the same + * hazard as the three above wearing different clothes: nothing on the error PAGE prints it, so the + * `href` audit that produced self::url() looked straight past it, and every API console, every IDE HTTP + * client and every documentation viewer that renders a problem document turns `type` into a link. A + * `javascript:` base configured here is the same stored XSS the three properties above are guarded + * against, published to a wider audience and arriving in a tool the reader trusts more than a 500 page. + * + * THE SENTINEL IS COMPARED WITH `===`, WHICH IS WHY THE TRIM IS NOT COSMETIC. ProblemType::of() asks + * whether the base IS 'about:blank' and whether it IS '', and a Helm block scalar's trailing newline or + * a here-doc's trailing space makes both answers false — so the deployment that meant "leave the default + * alone" silently entered BASE-URI mode and published `" about:blank/resource-not-found"` as a + * dereferenceable URI. Config::string() does not trim, Laravel's Env does not trim a REAL environment + * variable, and this was the one configuration-supplied URI in this class that reached its consumer + * verbatim. See self::typeUri() for the vocabulary, which is self::url()'s with one member swapped. + */ + public string $typeUri; + /** * @param list $jsonPaths path patterns that are answered as problem+json whatever the client asked for * @param array $views status (or `default`) => the Blade view to render instead @@ -117,6 +138,7 @@ * @param string $signIn the 401's sign-in target; '' offers no link. Guarded: see self::url() * @param string $support the "Contact support" target; '' offers no link. Guarded: see self::url() * @param bool $actions whether the page offers any navigation at all + * @param string $typeUri the RFC 9457 `type` base; '' omits the member. Guarded: see self::typeUri() */ public function __construct( public bool $enabled = true, @@ -146,11 +168,13 @@ public function __construct( public bool $problemFallback = true, // RFC 9457 §3.1.1's `type`: 'about:blank' (the default, and what Spring's ProblemDetail emits), '' // to omit the member, or a BASE URI from which the stable error code derives one. See ProblemType. - public string $typeUri = ProblemType::BLANK, + // Guarded like the three above, through the same constructor seam: see the property's docblock. + string $typeUri = ProblemType::BLANK, ) { $this->home = self::url($home); $this->signIn = self::url($signIn); $this->support = self::url($support); + $this->typeUri = self::typeUri($typeUri); } /** @@ -322,4 +346,48 @@ public static function url(string $value): string return $value === '/' || preg_match('#^/[^/\\\\]#', $value) === 1 ? $value : ''; } + + /** + * A base this class will let ProblemType build a published `type` out of, or the RFC's own sentinel. + * + * IT IS self::url()'s VOCABULARY WITH ONE MEMBER SWAPPED, and the swap is the whole difference between + * the two methods. An `href` on the error page may be an ABSOLUTE PATH, because a page is served from an + * origin and a path resolves against it. A problem `type` may not: RFC 9457 §3.1.1 wants a URI that + * identifies the problem kind across deployments, ProblemType::of() appends a slug to it, and a relative + * base would produce a type that means a different thing read from a different document. So the allowed + * set here is `''` (omit the member), the `about:blank` sentinel, and an absolute `http(s)://` base — + * nothing else. Everything self::url() refuses is refused for self::url()'s reasons, on top: a + * `javascript:` base is the same stored XSS in a member a console renders as a link, and `//evil.test` + * is the same silent change of origin. + * + * THE FALLBACK IS THE DEFAULT, NOT SILENCE. A value this method cannot read becomes 'about:blank' — the + * documented default and the RFC's own "no specific type" — rather than '' , because '' is a deliberate + * position an operator takes (publish the pre-9457 document byte for byte) and a typo must not be able + * to take it for them. A hostile base is answered by removing the hostility, not by removing the member. + * + * THE EDGES ARE TRIMMED FIRST, for self::url()'s reason and one more that is specific to this key. The + * URL standard strips leading and trailing C0 controls and space before it parses, so a padded value is + * re-read by every consumer as exactly the value this method allows; and ProblemType::of() compares the + * base to its two sentinels with `===`, so an untrimmed `"about:blank\n"` — which is what a Helm block + * scalar and a here-doc-rendered `.env` both hand over — would miss BOTH branches and publish + * `"about:blank\n/resource-not-found"` as a dereferenceable URI. Trimming is what makes the sentinels + * mean what an operator wrote. An INTERIOR tab, LF or CR is the opposite case and is refused, exactly as + * in self::url(): the parser deletes those from the middle, which is the whole reason `javascript:` + * is a javascript: URL, and a guard that keeps a string the reader will re-read differently has decided + * nothing. + */ + public static function typeUri(string $value): string + { + $value = trim($value, "\x00..\x20"); + + if ($value === '' || $value === ProblemType::BLANK) { + return $value; + } + + if (strpbrk($value, "\t\n\r") !== false) { + return ProblemType::BLANK; + } + + return preg_match('#^https?://#i', $value) === 1 ? $value : ProblemType::BLANK; + } } diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php index 6e50d4b4..89327ea0 100644 --- a/packages/web/src/Error/ProblemMapper.php +++ b/packages/web/src/Error/ProblemMapper.php @@ -136,13 +136,14 @@ public static function authoredDetail(Throwable $e): string * /api/api/orders/42. One character, and the member stops identifying the occurrence it exists to * identify. Spring's ProblemDetail sets `instance` from the request URI for the same reason. * - * THE PUBLISHED DOCUMENT IS NOT FIXED YET, AND THIS METHOD DOES NOT CLAIM IT IS. ProblemDetailsRenderer - * — the one surface that puts `instance` on the wire — still passes `$request->path()`, so a client - * still receives the relative form. The only caller here is ErrorReport, which hands the value to - * ErrorResponse and reads back the code, the category, the severity and the 405's verbs; the page's own - * path is built beside it. The rule arrives before its call sites deliberately, so that the conformance - * pass which changes the renderer moves one argument to an answer this package already tests, rather - * than restating what a root-relative reference is in a second place. + * THE PUBLISHED DOCUMENT IS FIXED, AND BOTH SURFACES NOW ARRIVE HERE. ProblemDetailsRenderer — the one + * surface that puts `instance` on the wire — passes this method's answer, so a client receives the + * root-relative form; ErrorReport, which hands the value to ErrorResponse and reads back the code, the + * category, the severity and the 405's verbs, is the other caller, and the page's own path is built + * beside it. That is the point of the rule living here rather than at either call site: two spellings of + * one reference would eventually disagree about one request, and the day the shape changes again it + * changes once. This paragraph used to record that the renderer had not been moved over yet, and the + * conformance pass that moved it retired the note, which is what that note promised. */ public static function instanceFor(Request $request): string { diff --git a/packages/web/tests/Error/ErrorPageSecurityTest.php b/packages/web/tests/Error/ErrorPageSecurityTest.php index e7c5a5cf..fd9404e9 100644 --- a/packages/web/tests/Error/ErrorPageSecurityTest.php +++ b/packages/web/tests/Error/ErrorPageSecurityTest.php @@ -96,6 +96,76 @@ } }); +/** + * The fifth configuration-supplied URI, and the first one that is not an `href`. + * + * `firefly.web.problem.type-uri` is the base ProblemType builds RFC 9457's `type` out of, and the RFC's own + * word for that member is "dereferenceable" — it exists so a person can open it. Nothing on the error PAGE + * prints it, which is exactly why it went unguarded while the four beside it did not: the audit that + * produced ErrorPageSettings::url() followed hrefs, and this value leaves by a different door. Every API + * console, IDE HTTP client and documentation viewer that renders a problem document turns `type` into a + * link, so `javascript:` here is the same stored XSS in front of a wider audience. + * + * AND THE SENTINELS ARE COMPARED WITH `===`, which makes the trim load-bearing rather than cosmetic. + * ProblemType::of() asks whether the base IS 'about:blank' and whether it IS ''; Config::string() does not + * trim and Laravel's Env does not touch a real environment variable, so the Helm block scalar and the + * here-doc-rendered `.env` this class's other guard was written for made BOTH answers false and quietly + * moved the deployment from the default into base-URI mode — publishing `" about:blank/resource-not-found"` + * as a URI a console invites a reader to click. + */ +$problemType = static fn (string $value): string => ErrorPageSettings::fromConfig( + new Config(new Repository(['firefly' => ['web' => ['problem' => ['type-uri' => $value]]]])), +)->typeUri; + +it('keeps the two sentinels and an absolute http(s) base, which is the whole legitimate vocabulary', function () use ($problemType) { + expect($problemType('about:blank'))->toBe('about:blank') + ->and($problemType(''))->toBe('') + ->and($problemType('https://api.example.test/problems'))->toBe('https://api.example.test/problems') + ->and($problemType('HTTP://legacy.example.test/p'))->toBe('HTTP://legacy.example.test/p') + // The default is the sentinel, for a settings object read from an empty configuration and for one + // built by hand — the guard runs in the constructor, like the four above it. + ->and(ErrorPageSettings::fromConfig(new Config(new Repository([])))->typeUri)->toBe('about:blank') + ->and((new ErrorPageSettings)->typeUri)->toBe('about:blank'); +}); + +it('trims the padding a deployment adds at the edges, so the RFC sentinel still reads as the sentinel', function () use ($problemType) { + // Each of these used to flip the key out of default mode: ProblemType::of() compares with ===, so a + // padded sentinel matched neither branch and became a base URI with a space or a newline inside it. + expect($problemType(' about:blank'))->toBe('about:blank') + ->and($problemType("about:blank\n"))->toBe('about:blank') + ->and($problemType("\tabout:blank\r\n"))->toBe('about:blank') + ->and($problemType("https://api.example.test/problems\n"))->toBe('https://api.example.test/problems') + ->and($problemType(' https://api.example.test/problems '))->toBe('https://api.example.test/problems') + // Whitespace and nothing else is the value an operator left blank, and '' is what it means: the + // member is omitted, which is the pre-9457 document byte for byte. + ->and($problemType(" \n "))->toBe(''); +}); + +it('refuses a hostile or relative base and falls back to the documented default rather than to silence', function () use ($problemType) { + foreach ([ + 'javascript:alert(document.cookie)', + 'JavaScript:alert(1)', + "java\tscript:alert(1)", + 'data:text/html;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMSk+', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + '//evil.test/problems', + '/\\evil.test/problems', + '/problems', + 'problems', + 'about:blank/', + "https://api.example.test/pro\nblems", + "https://api.example.test/pro\tblems", + ] as $value) { + // 'about:blank' and not '': '' is a position an operator takes deliberately, and a typo must not be + // able to take it for them. The answer to a hostile base is to remove the hostility, not the member. + expect($problemType($value))->toBe('about:blank', sprintf('%s reached the published `type`', var_export($value, true))); + } + + expect((new ErrorPageSettings(typeUri: 'javascript:alert(document.cookie)'))->typeUri)->toBe('about:blank') + ->and((new ErrorPageSettings(typeUri: ' about:blank'))->typeUri)->toBe('about:blank'); +}); + it('defaults home to the site root, offers no sign-in or support link, and renders the action row', function () use ($settings) { $defaults = $settings([]); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index bfef4609..c7cdc54d 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1135,6 +1135,16 @@ // | A code that slugs to nothing falls back to 'about:blank' rather than to the bare base: the // | base names the COLLECTION of problem types, not a member of it. // | + // | GUARDED, LIKE THE HREFS ABOVE, AND FOR A WIDER AUDIENCE. §3.1.1 calls `type` + // | dereferenceable, and every API console, IDE HTTP client and docs viewer that renders a + // | problem document turns it into a link — so a 'javascript:', 'data:' or '//host' base is + // | the same stored XSS the error page's links are guarded against, published to more + // | readers. ErrorPageSettings trims the edges (a Helm block scalar and a here-doc-rendered + // | .env both end in a newline, and an untrimmed sentinel is not the sentinel) and then + // | accepts only '', 'about:blank' or an absolute http(s) base; anything else becomes + // | 'about:blank', because '' is a position you take deliberately and a typo must not take it + // | for you. + // | // | Default: 'about:blank'. // */ // 'type-uri' => env('FIREFLY_WEB_PROBLEM_TYPE_URI', 'about:blank'), diff --git a/tests/DocsProseIsRealTest.php b/tests/DocsProseIsRealTest.php index 5c45d0b5..672bfb27 100644 --- a/tests/DocsProseIsRealTest.php +++ b/tests/DocsProseIsRealTest.php @@ -20,6 +20,10 @@ use Firefly\Data\Repository\Locking\HasOptimisticLock; use Firefly\Data\Repository\Locking\OptimisticLockException; use Firefly\Installer\CapabilityCatalog; +use Firefly\Kernel\Error\ErrorCategory; +use Firefly\Kernel\Error\ErrorResponse; +use Firefly\Kernel\Error\ErrorSeverity; +use Firefly\Kernel\Error\FieldError; use Firefly\Kernel\Exception\Infrastructure\OptimisticLockingFailureException; use Firefly\Observability\HttpExchanges\HeaderMasker; use Firefly\OpenApi\OpenApiProperties; @@ -1189,6 +1193,123 @@ function fireflyOAuth2InstallProse(array $client, array $server): array expect($paragraphs)->toBeGreaterThan(0); }); +it('pins every published problem document to the `instance` ProblemMapper really builds', function () { + // The twenty-ninth, and the fifth kind of wrong sentence at the one scale that hurts most: a document a + // reader COPIES. The RFC 9457 conformance pass made `instance` a root-relative reference — §3.1.5 makes + // the member a URI REFERENCE, and a relative one resolves against the document's base URI, so + // `api/v1/wallets/wlt-999` served from /api/v1/wallets/wlt-999 identified /api/v1/api/v1/wallets/wlt-999 + // — and left ten published samples and two explanatory paragraphs stating the behaviour it had just + // inverted. Both book editions did not merely show the old value: they SINGLED IT OUT as a lesson ("a + // small thing, and exactly the kind of small thing a client that compares strings gets wrong"), in the + // very file the pass edited. tests/DocsCodeIsRealTest.php could not see any of it, because a `json` + // fence naming no source file and a paragraph of prose are both outside what a listing guard compares. + // + // DERIVED, like the rest of this file: the reference is built by ProblemMapper::instanceFor() over a + // request for the very path the sample publishes, and the member ORDER is read off a real + // ErrorResponse::toArray(). Nothing below types out the answer, so the day either shape changes the + // failure names the samples that change with it. + $published = ProblemMapper::instanceFor(Request::create('/api/v1/wallets/wlt-999')); + + // The guard only has something to say while the two spellings really differ. If `instance` ever goes + // back to being the bare path, this stops asserting rather than asserting the wrong thing. + expect($published)->toBe('/api/v1/wallets/wlt-999') + ->and(Request::create('/api/v1/wallets/wlt-999')->path())->not->toBe($published); + + // The member order a document really carries, taken from the DTO rather than typed out: the standard + // members lead in toArray()'s order and every extension follows them. + $order = array_values(array_filter( + array_keys((new ErrorResponse(404, 'Not Found', 'X', ErrorCategory::Business, ErrorSeverity::Warning, + detail: 'd', type: 'about:blank', instance: '/x', traceId: 't', + errors: [new FieldError('f', 'm')], timestamp: 'ts', + extensions: ['allowed' => ['POST']], correlationId: 'c'))->toArray()), + static fn (string $key): bool => in_array($key, ErrorResponse::STANDARD_MEMBERS, true), + )); + + $root = dirname(__DIR__); + $samples = 0; + $failures = []; + + foreach (array_keys(fireflyProsePages()) as $page) { + foreach (fireflyFencedBlocks($root.'/'.$page) as $block) { + preg_match_all('/"instance"\s*:\s*"([^"]*)"/', $block['code'], $found); + + foreach ($found[1] as $value) { + $samples++; + $expected = ProblemMapper::instanceFor(Request::create('/'.ltrim($value, '/'))); + + if ($value !== $expected) { + $failures[] = sprintf( + '%s:%d publishes "instance": "%s"; the framework publishes "%s" — RFC 9457 §3.1.5 ' + .'makes the member a URI reference, and a reader copying this sample gets a document ' + .'ProblemMapper::instanceFor() cannot produce.', + $page, + $block['line'], + $value, + $expected, + ); + } + } + + // A sample that is a whole document is also held to the member ORDER toArray() writes, so a + // member added to the published shape cannot be pasted into these samples in the wrong place. + if ($found[1] === []) { + continue; + } + + $decoded = json_decode($block['code'], true); + + if (! is_array($decoded)) { + continue; + } + + $present = array_values(array_filter(array_keys($decoded), static fn (mixed $key): bool => is_string($key) && in_array($key, ErrorResponse::STANDARD_MEMBERS, true))); + $wanted = array_values(array_filter($order, static fn (string $key): bool => in_array($key, $present, true))); + + if ($present !== $wanted) { + $failures[] = sprintf( + '%s:%d publishes its standard members as %s; ErrorResponse::toArray() writes them as %s.', + $page, + $block['line'], + implode(', ', $present), + implode(', ', $wanted), + ); + } + } + } + + // And the prose beside them. A paragraph that explains `instance` and names `$request->path()` is + // describing the value the renderer stopped passing, so it has to name what passes it instead — which is + // the correction the shipped paragraphs were missing, in either language, without this file having to + // read Spanish. + $paragraphs = 0; + + foreach (fireflyProsePages() as $page => $pageParagraphs) { + foreach ($pageParagraphs as $paragraph) { + if (! str_contains($paragraph, '`instance`') || ! str_contains($paragraph, '$request->path()')) { + continue; + } + + $paragraphs++; + + if (! str_contains($paragraph, 'instanceFor')) { + $failures[] = sprintf( + '%s explains `instance` as `$request->path()` without naming ProblemMapper::instanceFor(), ' + .'which is what the renderer passes and what makes the published member "%s".', + $page, + $published, + ); + } + } + } + + expect($failures)->toBe([]) + // Canaries, on both halves: a check that stops matching anything proves nothing. Eleven samples and + // two paragraphs are what the surface held the day this was written, and the wave still has pages to + // add — so the floor is asserted rather than the exact count. + ->and($samples)->toBeGreaterThanOrEqual(11) + ->and($paragraphs)->toBeGreaterThanOrEqual(2); +}); + it('pins every stereotype-inheritance sentence to the class hierarchy PHP really declares', function () { // The fifth kind of wrong sentence: one that states a real relation BACKWARDS. Both tutorials shipped // "The same route scan finds both (`#[RestController]` extends `#[Controller]`)" — the inverse of From 7ec494b832f608aea3388b0d644765d62a181b23 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 23 Sep 2026 22:21:02 -0700 Subject: [PATCH 58/78] =?UTF-8?q?test(browser):=20exercise=20the=20dashboa?= =?UTF-8?q?rd=20at=20sixty=20rows=20=E2=80=94=20the=20pager,=20a=20tied=20?= =?UTF-8?q?sort=20across=20pages=20and=20the=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Browser/AdminTablesTest.php | 150 ++++++++++++++++++++++++++ tests/Browser/Support/SeedsOrders.php | 48 +++++++++ 2 files changed, 198 insertions(+) diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php index 3011dd35..aa8dacc5 100644 --- a/tests/Browser/AdminTablesTest.php +++ b/tests/Browser/AdminTablesTest.php @@ -549,3 +549,153 @@ JS, true) ->assertNoJavaScriptErrors(); }); + +/** + * NOTHING IN THIS TREE EXERCISED THE DASHBOARD AT SCALE, and that gap is itself the finding: the Routes + * screenshot that started this wave has seven rows on it, and the defect it shows is a layout that cannot + * survive eight. These scenarios page a sixty-row listing, walk it, and assert the union of the pages is + * the table — which is the only way a paging bug is visible at all. + */ +it('pages a sixty-row listing and every page is a different, complete slice', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + $this->seedManyOrders(60); + + $page = visit('/firefly/data?resource=order-entity&size=25&sort=customer'); + + $page->assertSee('60 total') + ->assertSee('1–25 of 60') + ->assertSee('Customer 001') + ->assertDontSee('Customer 026') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'admin-data-paged'); + + $page->click('Next') + ->assertQueryStringHas('page', '2') + ->assertSee('26–50 of 60') + ->assertSee('Customer 026') + ->assertDontSee('Customer 001') + // The sort survived the click. It used to survive it by being concatenated into the href by hand, + // four strings at a time. + ->assertQueryStringHas('sort', 'customer') + // AND SO DID THE SIZE, THOUGH NOT THROUGH THE URL — which is the point of asserting it here rather + // than with assertQueryStringHas. 25 IS this listing's default (`firefly.admin.data.page-size`), + // and ListingQuery::meaningful() omits a parameter that is already at its default, so that a + // bookmark taken today cannot pin a size an operator later reconfigures. What proves the size + // survived is the slice — 26–50, twenty-five rows — under a control that still reads 25. + ->assertScript("document.querySelector('.pager select').value", '25') + ->assertNoJavaScriptErrors(); + + $page->click('3') + ->assertSee('51–60 of 60') + ->assertSee('Customer 060') + ->assertNoJavaScriptErrors(); +}); + +/** + * THE STABLE SORT, END TO END. Every one of these sixty rows carries the same `created_at` — seedManyOrders + * reads the clock once, above its loop — so ordering by it is a sixty-way tie, exactly the case where a + * listing without a tiebreak shows a row twice and another never. Three pages are walked as a reader walks + * them, over real requests, and the union of what they DREW is asserted to be the whole table. + * + * WHAT THIS ADDS OVER packages/admin/tests/Data/DataStableSortTest.php, and what it does not. That suite + * owns the ORDER BY: it pins the identifier onto the clause, ascending under a descending primary sort, and + * it fails the moment the tiebreak is dropped. This one cannot make that claim honestly — sqlite's sorter + * happens to be stable over a table this size, so the same three pages come back consistent with the + * tiebreak removed, which was measured rather than assumed. What it pins is everything BETWEEN the clause + * and the reader: three offsets, three rebuilt URLs and three renders, adding up to sixty distinct rows and + * no row drawn twice. A slice computed from the wrong size, a page link that lost the sort, or an offset + * off by a page are all invisible to a query test and all visible here. + * + * THE TIED COLUMN IS `created_at` AND NOT `ship_to`, because `DataSchema::sortable()` excludes json columns + * by design — ordering a serialized blob sorts its text, which looks like it worked and means nothing — so + * `?sort=ship_to` is dropped by ListingQuery and the listing falls back to its identifier, which is the one + * ordering that has no ties to break. A tie has to be asked for in a column the page really orders by. + */ +it('shows every row exactly once when paging a sort whose values all tie', function (): void { + /** @var AdminDashboardBrowserTestCase $this */ + $this->seedManyOrders(60); + + // The customer column, which is the second one every resource listing draws after its identifier, and + // the one column of this fixture whose sixty values are distinct by construction. `script()` answers + // `mixed`, so the names are narrowed as they are collected rather than asserted about as they are: + // anything that is not a string lands as '' and collapses under array_unique, which fails the + // assertion below instead of quietly passing it. + $seen = []; + foreach ([1, 2, 3] as $number) { + $page = visit('/firefly/data?resource=order-entity&size=25&sort=created_at&dir=desc&page='.$number); + $names = $page->script("[...document.querySelectorAll('tbody tr td:nth-child(2)')].map(c => c.textContent.trim())"); + + foreach (is_array($names) ? $names : [] as $name) { + $seen[] = is_string($name) ? $name : ''; + } + } + + expect($seen)->toHaveCount(60)->and(array_unique($seen))->toHaveCount(60); +}); + +/** + * The rows-per-page control had no submit button and did nothing with scripts off. It has one now, and + * pressing it is the assertion. + * + * THE ONCHANGE IS TAKEN AWAY FIRST, which is the only way this scenario can mean what its name says. + * `` whose value has no `
    + @include('firefly-admin::_table-head', ['view' => $bindingView, 'query' => null]) + @foreach ($detail->caller as $binding) + + + + + + + + + + + @endforeach +
    {{ $binding->position }}${{ $binding->plan['name'] }}{{ $binding->plan['kind'] }}{{ $binding->plan['key'] }}{{ Format::leafOf($binding->plan['type'] ?? 'not recorded') }}{{ Format::stemOf($binding->plan['type'] ?? '') }}{{ $binding->plan['required'] ? 'yes' : 'no' }}{{ $binding->defaultLabel() }}{{ $binding->plan['valid'] ? 'yes' : 'no' }}
    + + @foreach ($detail->caller as $binding) + @if ($binding->notFoundMessage !== null) +

    ${{ $binding->plan['name'] }} pattern ^(?:{{ $binding->plan['pattern'] }})$ (case-insensitive). A mismatch returns 404 {{ $binding->plan['notFoundCode'] ?? 'RESOURCE_NOT_FOUND' }}: {{ $binding->notFoundMessage }}

    + @endif + @endforeach + @endif + +
    +

    Before the controller · possible binding failures

    +

    Framework defaults derived from this contract. Exception handlers may replace the response or status. Resolver-specific failures are not inferred.

    + @if ($detail->failures === [])

    No default binding failures are implied by these arguments.

    + @else
      @foreach ($detail->failures as $failure) +
    • {{ $failure['status'] }} {{ $failure['code'] }} · ${{ $failure['binding'] }}{{ $failure['reason'] }}
    • + @endforeach
    @endif +
    + {{-- Services are collaborators, never request parameters. A resolver claim overrides the compiled kind. --}} +
    +

    Handler arguments · what the container supplies

    + @if ($detail->injected === [])

    No injected arguments in this signature.

    + @else
      @foreach ($detail->injected as $binding) +
    1. ${{ $binding->plan['name'] }} · {{ $binding->plan['type'] ?? 'not recorded (union, intersection or untyped)' }} + @if ($binding->resolver !== null)Resolver claimed

      Supplied by {{ $binding->resolver }}; compiled kind {{ $binding->plan['kind'] }} is overridden.

      + @elseContainer service@endif + @if (array_key_exists('nullable', $binding->plan)) · {{ $binding->plan['nullable'] ? 'may be null' : 'non-null' }}@endif +
    2. + @endforeach
    @endif +
    + @foreach ($detail->caller as $binding) + @if ($binding->plan['kind'] === 'body') +
    +

    Request body · ${{ $binding->plan['name'] }}

    +

    {{ $binding->plan['valid'] && $binding->plan['type'] !== null ? 'Validated before hydration; validation can answer 422.' : 'No body validation is declared.' }} Validation rules are not part of the route manifest.

    + @php $bodyNodes = RouteBodyNode::forBinding($binding); @endphp + @if ($bodyNodes !== [])@include('firefly-admin::_route-body', ['nodes' => $bodyNodes, 'depth' => 0]) + @else

    {{ $binding->plan['type'] === null || ! class_exists($binding->plan['type']) ? 'The decoded array is passed through.' : 'No constructor properties are recorded in this manifest.' }}

    @endif +
    + @endif + @endforeach +
    +

    Response

    +

    Default success status {{ $route->status }}. A returned response can override it.

    +

    {{ $route->html ? 'The controller is declared as an HTML page (text/html).' : 'Negotiated from Accept and the returned value’s type. Older manifests may not record the HTML stereotype.' }}

    + @if ($route->html)

    HTML controllers are excluded from the OpenAPI document unless firefly.openapi.include-html is enabled.

    @endif +

    Exception handlers

    +

    Controller-local handlers take precedence over global handlers; within that scope, the most-derived matching exception class wins.

    + @if ($handlers === [])

    No matching local or global exception handlers are registered.

    + @else
      @foreach ($handlers as $handler)
    • {{ $handler->global ? 'Global' : 'Controller-local' }} · {{ $handler->exceptionClass }} → {{ $handler->handlerClass }}::{{ $handler->methodName }}()
    • @endforeach
    @endif +
    + @if ($settings->routeAdvice) +
    Advice · compiled contract and runtime bindings +

    Source: {{ $adviceSource }}. Listed outermost first. LIVE means the interceptor is bound; it does not prove a request executed it. UNBOUND advice can fail proxy creation unless explicitly permitted to be INERT.

    + @if ($advice === [])

    No method advice is recorded in the available plan.

    + @else
      @foreach ($advice as $item)
    1. {{ $item['id'] }} · order {{ $item['order'] }} · {{ $item['state'] }}

      {{ $item['interceptor'] }}

      Declared settings and meter names
      {{ $item['contract'] }}
    2. @endforeach
    @endif +

    Meter names are declarations; metric totals belong to the application, not this route.

    +
    + @endif + @if ($metadata !== null) +
    As Laravel registered it +

    URI {{ $metadata['uri'] }} · name {{ $metadata['name'] ?? 'unnamed' }} · domain {{ $metadata['domain'] ?? 'any host' }}

    +

    Declared route middleware: {{ implode(', ', $metadata['middleware']) ?: 'none' }}. Global middleware is not included.

    + @foreach ($metadata['patterns'] as $name => $pattern)

    {{ $name }} constraint: {{ $pattern }}

    @endforeach +
    + @endif +
    Dispatch order and inspection limits +
    1. Resolve the controller.
    2. Bind arguments in signature order: +
        @foreach ($detail->arguments() as $binding)
      1. ${{ $binding->plan['name'] }} · {{ $binding->resolver !== null ? 'registered resolver' : ($binding->plan['kind'] === 'service' ? 'container service' : $binding->plan['kind'].' binding') }}
      2. @endforeach
      + See binding failures above.
    3. Check controller authorization after arguments are available.
    4. Invoke the handler and apply after-invocation checks.
    5. Build the response with default status {{ $route->status }}.
    + {{-- Proxy security absence is not an authorization verdict; URL rules alone cannot establish public access. --}} +

    Security authorization is not inferred from this page. Controller and method checks, URL rules and the concrete request may all affect access.

    +

    No handler, argument resolver or synthetic request is executed by this inspection. HTTP traffic is a separate application-wide view.

    +
    +@endsection +@push('scripts') + +@endpush diff --git a/packages/admin/resources/views/mappings.blade.php b/packages/admin/resources/views/mappings.blade.php index 9f8ac5b4..57f19b37 100644 --- a/packages/admin/resources/views/mappings.blade.php +++ b/packages/admin/resources/views/mappings.blade.php @@ -26,7 +26,14 @@ @foreach ($slice->rows as $route)
    {{ $route['httpMethod'] }}{{ $route['path'] }} + @if ($settings->routeDetail) + Inspect {{ $route['httpMethod'] }} {{ $route['path'] }} + @else + {{ $route['path'] }} + @endif + @if ($route['shadowed'])Shadowed@endif + {{ Format::leafOf($route['handler']) }} {{ Format::stemOf($route['handler']) }} diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php index aa59d713..f46f6996 100644 --- a/packages/admin/src/AdminSettings.php +++ b/packages/admin/src/AdminSettings.php @@ -35,6 +35,8 @@ public function __construct( public int $graphMaxNodes = 220, public array $excludedPages = [], public TableSettings $table = new TableSettings, + public bool $routeDetail = true, + public bool $routeAdvice = true, ) {} public static function fromConfig(Config $config): self @@ -58,6 +60,8 @@ public static function fromConfig(Config $config): self // because a Blade view reaches exactly one settings object, and a second one would mean every // view that draws a table taking a second parameter through render(). table: TableSettings::fromConfig($config), + routeDetail: $config->bool('firefly.admin.routes.detail', true), + routeAdvice: $config->bool('firefly.admin.routes.advice', true), ); } diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 41ed8852..2d072726 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -17,6 +17,8 @@ use Firefly\Admin\Data\DataMap; use Firefly\Admin\Data\DatasourceReport; use Firefly\Admin\Format; +use Firefly\Admin\Route\RouteDetail; +use Firefly\Admin\Route\RouteInspector; use Firefly\Admin\RowComparator; use Firefly\Admin\Settings\FeatureToggle; use Firefly\Admin\Settings\SettingsConsole; @@ -26,11 +28,14 @@ use Firefly\Admin\Table\TableColumn; use Firefly\Admin\Table\TableView; use Firefly\Context\Scan\AppScan; +use Firefly\Web\Exception\ExceptionHandlerDescriptor; +use Firefly\Web\Exception\ExceptionHandlerRegistry; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Routing\Router; use Illuminate\Session\Store; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Throwable; @@ -88,6 +93,10 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse return $this->html($this->render('unavailable', ['page' => $current]), 404); } + if ($slug === 'mappings' && $request->query('route') !== null) { + return $this->mappingPage($request, $current); + } + if ($slug === 'loggers' && $request->isMethod('POST')) { return $this->setLoggerLevel($request); } @@ -115,6 +124,79 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($request, $slug), $current), 200); } + private function mappingPage(Request $request, AdminPage $current): SymfonyResponse + { + $key = $request->query('route'); + $inspector = new RouteInspector($this->container); + $detail = $this->settings->routeDetail && is_string($key) ? $inspector->detail($key) : null; + if ($detail === null) { + return $this->html($this->render('missing', ['slug' => 'route']), 404); + } + $manifestFile = AppScan::cachedFile($this->container, AppScan::ROUTES); + $manifestTime = $manifestFile !== null ? filemtime($manifestFile) : false; + $query = ListingQuery::fromRequest($request, $this->settings->table, $this->settings->url('mappings'), ['httpMethod', 'path', 'handler', 'name']); + $handlers = $this->container->bound(ExceptionHandlerRegistry::class) + ? array_values(array_filter($this->container->make(ExceptionHandlerRegistry::class)->all(), + static fn (ExceptionHandlerDescriptor $handler): bool => $handler->global || $handler->handlerClass === $detail->route->controllerClass)) : []; + + return $this->html($this->render('mapping', [ + 'detail' => $detail, 'query' => $query, 'handlers' => $handlers, + 'bootMode' => $manifestFile !== null ? 'compiled' : 'in-process manifest', + 'manifestTime' => $manifestTime, + 'advice' => $this->settings->routeAdvice ? $inspector->advice($detail->route) : [], + // File-existence probes only: never require another package's compiled artifact. + 'adviceSource' => AppScan::cachedFile($this->container, AppScan::PROXY_PLAN) !== null ? 'proxy-plan.php' + : (AppScan::cachedFile($this->container, AppScan::TRANSACTIONAL) !== null ? 'transactional.php only — recompile for the complete advice plan' : 'in-process plan, if registered'), + 'metadata' => $inspector->metadata($detail->route), + 'links' => $this->mappingLinks($detail), + 'bindingView' => TableView::of(TableColumn::number('position', '#', ch: 3), TableColumn::token('name', 'PHP argument', weight: 2), + TableColumn::pill('kind', 'From', ch: 8), TableColumn::token('key', 'Wire name', weight: 2), TableColumn::qualified('type', 'Type', weight: 3), + TableColumn::pill('required', 'Required', ch: 8), TableColumn::token('default', 'Compiled default', weight: 2), TableColumn::pill('valid', 'Valid', ch: 5)), + ], $current), 200); + } + + /** @return list */ + private function mappingLinks(RouteDetail $detail): array + { + $allowed = array_column($this->nav(), 'slug'); + $links = []; + foreach (['http' => 'HTTP traffic', 'metrics' => 'Metrics'] as $slug => $label) { + if (in_array($slug, $allowed, true)) { + $links[] = ['label' => $label, 'url' => $this->settings->url($slug)]; + } + } + if (in_array('beans', $allowed, true)) { + $links[] = ['label' => 'Controller beans', 'url' => $this->settings->url('beans').'?q='.rawurlencode($detail->route->controllerClass)]; + } + $properties = in_array('configprops', $allowed, true) ? $this->subArray($this->payload('configprops'), 'beans') : []; + if (in_array('graph', $allowed, true)) { + $graph = BeanGraph::build($this->listOf('beans', 'beans'), $properties); + foreach ($graph->nodes as $node) { + if ($node['id'] === $detail->route->controllerClass) { + $links[] = ['label' => 'Controller wiring', 'url' => $this->settings->url('graph').'?bean='.rawurlencode($node['id'])]; + } + } + foreach ($graph->edges as $edge) { + if ($edge['from'] === $detail->route->controllerClass && isset($properties[$edge['to']])) { + $links[] = ['label' => 'Configuration: '.Format::leafOf($edge['to']), 'url' => $this->settings->url('configprops').'?q='.rawurlencode($edge['to'])]; + } + } + } + foreach ($detail->injected as $binding) { + $type = $binding->plan['type']; + if ($binding->resolver === null && $type !== null && isset($properties[$type])) { + $links[] = ['label' => 'Configuration: '.Format::leafOf($type), 'url' => $this->settings->url('configprops').'?q='.rawurlencode($type)]; + } + } + $router = $this->container->bound('router') ? $this->container->make('router') : null; + $viewer = $router instanceof Router ? $router->getRoutes()->getByName('firefly.openapi.viewer') : null; + if ($viewer !== null && $viewer->getDomain() === null && ! str_contains($viewer->uri(), '{')) { + $links[] = ['label' => 'API reference', 'url' => '/'.ltrim($viewer->uri(), '/')]; + } + + return $links; + } + private function settingsPage(AdminPage $current): SymfonyResponse { if (! $this->console->isEnabled()) { @@ -844,7 +926,7 @@ private function configPropsPage(Request $request): array * to compare strings rather than "whatever the endpoint put there" — one int in that column and * strnatcasecmp is comparing a number to a name. * - * @return list + * @return list */ private function mappingRows(): array { @@ -864,6 +946,15 @@ private function mappingRows(): array $rows[] = $row; } + $last = []; + foreach ($rows as $index => $row) { + $last[$row['httpMethod'].' '.$row['path']] = $index; + } + foreach ($rows as $index => &$row) { + $row['shadowed'] = $last[$row['httpMethod'].' '.$row['path']] !== $index; + } + unset($row); + return $rows; } diff --git a/packages/admin/tests/RouteDetailPageTest.php b/packages/admin/tests/RouteDetailPageTest.php new file mode 100644 index 00000000..0564b8a5 --- /dev/null +++ b/packages/admin/tests/RouteDetailPageTest.php @@ -0,0 +1,148 @@ +get('/firefly/mappings?q=orders&sort=path&size=25')->assertOk() + ->assertSee('data-route="GET /orders/{id}"', false) + ->assertSee('route=GET%20%2Forders%2F%7Bid%7D#route-detail', false); + $this->get('/firefly/mappings?q=orders&sort=path&size=25&route=GET%20%2Forders%2F%7Bid%7D')->assertOk() + ->assertSee('id="route-detail" tabindex="-1"', false) + ->assertSee('Back to routes')->assertSee('Request · what the caller sends') + ->assertSee('href="/firefly/mappings?q=orders&sort=path&size=25"', false) + ->assertSee('Same controller')->assertSee('Default success status'); +}); + +it('returns a hard 404 for malformed and missing route selectors', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->get('/firefly/mappings?route[]=GET')->assertNotFound(); + $this->get('/firefly/mappings?route=GET%20%2Fmissing')->assertNotFound(); +}); + +it('renders ordered bindings patterns and claimed resolver arguments without treating them as caller input', function () { + /** @var AdminTableCapstoneTestCase $this */ + $binding = static fn (string $name, string $kind, ?string $type = 'string'): array => ['name' => $name, 'kind' => $kind, 'key' => $name, 'type' => $type, 'required' => true, 'default' => null, 'valid' => false, 'properties' => []]; + $this->app()->instance(RouteManifest::class, new RouteManifest([ + new RouteDescriptor('POST', '/contract/{id}', 'App\\Contract', 'create', 201, 'contract', [ + [...$binding('id', 'path', 'int'), 'pattern' => '[0-9]+'], $binding('q', 'query'), $binding('X-Trace', 'header'), $binding('upload', 'file'), + [...$binding('body', 'body', stdClass::class), 'valid' => true, 'properties' => ['title']], $binding('service', 'service', 'UnboundCollaborator'), + ]), + ])); + $this->get('/firefly/mappings?route=POST%20%2Fcontract%2F%7Bid%7D')->assertOk() + ->assertSee('That resource does not exist.')->assertSee('201') + ->assertSee('MISSING_PARAMETER')->assertSee('UNBINDABLE_BODY')->assertSee((new ValidationException)->errorCode()) + ->assertSee('what the container supplies')->assertSee('UnboundCollaborator')->assertSee('title') + ->assertSee('Security authorization is not inferred'); +}); + +it('honors route detail and mappings page switches with hard refusals', function (string $key, mixed $value) { + /** @var AdminTableCapstoneTestCase $this */ + $config = $this->app()->make(Repository::class); + $config->set($key, $value); + $this->app()->instance(AdminSettings::class, AdminSettings::fromConfig(new Config($config))); + $this->get('/firefly/mappings?route=GET%20%2Forders')->assertNotFound(); + if ($key === 'firefly.admin.routes.detail') { + $this->get('/firefly/mappings')->assertOk()->assertDontSee('data-route="', false); + } +})->with([['firefly.admin.routes.detail', false], ['firefly.admin.pages.exclude', 'mappings']]); + +it('keeps optional crosslinks behind destination gates and does not read detail collaborators on the listing', function () { + /** @var AdminTableCapstoneTestCase $this */ + $config = $this->app()->make(Repository::class); + $config->set('firefly.admin.pages.exclude', 'graph,beans,configprops,http,metrics'); + $config->set('firefly.admin.routes.advice', false); + $this->app()->instance(AdminSettings::class, AdminSettings::fromConfig(new Config($config))); + $this->app()->bind(ProxyPlan::class, fn () => throw new RuntimeException('Advice disabled')); + $this->get('/firefly/mappings')->assertOk(); + $this->get('/firefly/mappings?route=GET%20%2Forders')->assertOk() + ->assertDontSee('href="/firefly/graph', false)->assertDontSee('href="/firefly/configprops', false) + ->assertDontSee('href="/firefly/http', false)->assertDontSee('href="/firefly/metrics', false) + ->assertDontSee('Advice · compiled contract'); +}); + +it('badges shadowed list rows and displays both registrations with the last effective', function () { + /** @var AdminTableCapstoneTestCase $this */ + $routes = [new RouteDescriptor('GET', '/duplicate', 'App\\Old', 'index', 200, 'old', []), new RouteDescriptor('GET', '/duplicate', 'App\\New', 'index', 202, 'new', [])]; + $this->app()->instance(RouteManifest::class, new RouteManifest($routes)); + $this->app()->make(ActuatorRegistry::class)->register(new MappingsEndpoint(new RouteManifest($routes))); + $this->get('/firefly/mappings')->assertOk()->assertSee('Shadowed'); + $this->get('/firefly/mappings?route=GET%20%2Fduplicate')->assertOk()->assertSee('Duplicate registrations') + ->assertSee('App\\Old')->assertSee('App\\New')->assertSee('202')->assertSee('Effective'); +}); + +it('uses the registered API viewer route and only actual configuration collaborators as joins', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->app()->make(Router::class)->get('/reference', fn () => 'viewer')->name('firefly.openapi.viewer'); + $type = BillingProperties::class; + $this->app()->instance(RouteManifest::class, new RouteManifest([ + new RouteDescriptor('GET', '/configured', 'App\\Configured', 'index', 200, null, [['name' => 'config', 'kind' => 'service', 'key' => 'config', 'type' => $type, 'required' => true, 'default' => null, 'valid' => false, 'properties' => []]]), + ])); + $this->get('/firefly/mappings?route=GET%20%2Fconfigured')->assertOk()->assertSee('href="/reference"', false) + ->assertSee('Configuration: BillingProperties')->assertSee('href="/firefly/http"', false); +}); + +it('renders a principal-style resolver claim and never calls resolve or derives query failures for it', function () { + /** @var AdminTableCapstoneTestCase $this */ + $resolvers = new HandlerMethodArgumentResolvers; + $resolvers->add(new class implements HandlerMethodArgumentResolver + { + public function supports(array $binding): bool + { + return in_array('PrincipalAttribute', is_array($binding['attributes'] ?? null) ? $binding['attributes'] : [], true); + } + + public function resolve(array $binding, Request $request): mixed + { + throw new RuntimeException('Inspection must never resolve a principal'); + } + }); + $this->app()->instance(HandlerMethodArgumentResolvers::class, $resolvers); + $this->app()->instance(RouteManifest::class, new RouteManifest([ + new RouteDescriptor('GET', '/principal', 'App\\PrincipalController', 'show', 200, null, [['name' => 'subject', 'kind' => 'query', 'key' => 'subject', 'type' => 'int', 'required' => true, 'default' => null, 'valid' => false, 'properties' => [], 'attributes' => ['PrincipalAttribute'], 'nullable' => true]]), + ])); + $this->get('/firefly/mappings?route=GET%20%2Fprincipal')->assertOk()->assertSee('Resolver claimed') + ->assertSee('may be null')->assertSee('No caller-supplied arguments') + ->assertDontSee('MISSING_PARAMETER')->assertDontSee('TYPE_CONVERSION_ERROR'); +}); + +it('does not inspect resolvers advice or handlers for an ordinary route listing', function () { + /** @var AdminTableCapstoneTestCase $this */ + $app = $this->app(); + foreach ([HandlerMethodArgumentResolvers::class, ProxyPlan::class, ExceptionHandlerRegistry::class] as $class) { + $app->bind($class, fn () => throw new RuntimeException('Detail collaborator resolved on listing')); + } + $this->get('/firefly/mappings')->assertOk()->assertSee('Mappings'); +}); + +it('shows only matching exception advice and distinguishes an HTML stereotype', function () { + /** @var AdminTableCapstoneTestCase $this */ + $this->app()->instance(ExceptionHandlerRegistry::class, new ExceptionHandlerRegistry([ + new ExceptionHandlerDescriptor(RuntimeException::class, 'App\\Http\\WelcomeController', 'localFailure', false), + new ExceptionHandlerDescriptor(Throwable::class, 'App\\GlobalAdvice', 'globalFailure', true), + new ExceptionHandlerDescriptor(Throwable::class, 'App\\Unrelated', 'unrelatedFailure', false), + ])); + $this->get('/firefly/mappings?route=GET%20%2F')->assertOk()->assertSee('HTML page') + ->assertSee('localFailure')->assertSee('globalFailure')->assertDontSee('unrelatedFailure') + ->assertSee('firefly.openapi.include-html'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 91c5d600..477fc397 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -1318,6 +1318,14 @@ // 'density' => env('FIREFLY_ADMIN_TABLE_DENSITY', 'comfortable'), // ], // + // // Route permalinks read the compiled binding contract without executing a handler or resolver. + // 'routes' => [ + // // Default true. False removes route links and refuses every ?route= URL with 404. + // 'detail' => env('FIREFLY_ADMIN_ROUTES_DETAIL', true), + // // Default true. Show the collapsed compiled advice plan, its provenance and binding state. + // 'advice' => env('FIREFLY_ADMIN_ROUTES_ADVICE', true), + // ], + // // 'pages' => [ // /* // | CSV of page slugs to REFUSE. This is a refusal, not a menu preference: an excluded page is From 33e20da3c34a3f06bf0414ed9727b370b9a84a04 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 29 Sep 2026 13:30:22 -0700 Subject: [PATCH 72/78] test(admin): verify route detail navigation and document its contract Exercise real JavaScript-disabled keyboard navigation, focus, phone table containment and light/dark color contrast. Preserve the original one-line route path geometry assertion by measuring visible path text after links gain hidden verb text. Cover resolver claims, endpoint and page gates, all advice binding states, and documentation for route switches and inspection limits. --- CHANGELOG.md | 6 ++ docs/modules/admin.md | 45 ++++++++++++++ .../admin/tests/Route/RouteInspectorTest.php | 7 ++- packages/admin/tests/RouteDetailPageTest.php | 2 +- tests/Browser/AdminTablesTest.php | 5 +- tests/Browser/RouteDetailTest.php | 60 +++++++++++++++++++ 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/Browser/RouteDetailTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0412ee..ab193324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y ### Added +- **Route detail:** permalinked, server-rendered route contracts with ordered caller/injected bindings, + resolver claims, binding-specific failures, bounded DTO trees, sibling comparison and duplicate warnings. + Gated wiring/configuration/API/traffic links, default responses, exception handlers, registered route + metadata and collapsed advice provenance make the compiled contract inspectable without running it. + `firefly.admin.routes.detail` and `firefly.admin.routes.advice` control the new surface. + - **Error navigation:** configured sign-in on 401, retry on GET/HEAD 5xx, and home/support links where configured. Production ledes retain safe authored details and 405 pages name the allowed methods. - **Error configuration:** documented `max-frames`, `home`, `sign-in`, `support`, `actions`, `copy-button`, diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 1c17041d..9aeed7d9 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -482,3 +482,48 @@ See also: [Actuator](actuator.md) for the endpoints themselves, [Observability]( and HTTP-exchange stores the dashboard renders, [Bean Graph](bean-graph.md) for the one page that is more than a table, and [Data Browser](data-browser.md) for the Django-style view over your own repositories — which is **off by default and does not inherit `firefly.admin.enabled`**. + +## Inspecting a route + +Select a path in Routes to open its contract at +`/firefly/mappings?route=GET%20%2Forders%2F%7Bid%7D#route-detail`. +The ordinary link works without JavaScript; the optional focus enhancement moves to the route heading. +Search, sorting, page and size travel with the link and the return link. No detail manifests, resolver +claims, advice or wiring joins are read on an ordinary listing request. + +The page reads the booted Web `RouteManifest`, without widening `/actuator/mappings`. It shows handler +arguments in signature order, including their wire keys, types, required flags, **compiled attribute +defaults** and validation flags. A missing type means unrecorded (possibly union, intersection or untyped); +a null default is not evidence of nullability. Resolver claims take precedence over compiled kinds: +`#[AuthenticationPrincipal]` can compile as `query` but is shown as injected, alongside services. +Inspection calls only `supports()` through `resolverFor()` and never executes `resolve()` or the handler. + +Possible failures are tied to each unclaimed caller binding. Required path/query/header/file inputs may +produce `MISSING_PARAMETER`; primitive conversion and multi-file arrays may produce +`TYPE_CONVERSION_ERROR`. A path pattern mismatch is 404 with its effective code and sentence. Bodies may +produce `MALFORMED_BODY` or `INVALID_REQUEST`; a loadable DTO adds `UNBINDABLE_BODY`, and a typed `valid` +body adds a possible 422. These are framework defaults: controller-local and global exception handlers +can replace responses. Required body/service arguments do not imply a missing-parameter failure. + +DTO shapes prefer the compiled `dtos` table and fall back to legacy property names. Native disclosures +keep the first two levels open, indentation is bounded, recursive references terminate, and a display +budget limits very large trees. On phones the binding table scrolls horizontally inside its own region. +The success status is a default; returned responses can override it. HTML stereotypes are shown explicitly, +while older manifests with no HTML flag remain described as negotiated rather than promised JSON. + +Controller siblings appear near the heading with comparison markers. Duplicate verb/path registrations +are marked **Shadowed** in the list and shown in manifest order on the detail page; the last wins. +Related links respect each destination's page and endpoint gates. Configuration links identify known +injected configuration collaborators, never request DTOs. The API reference link uses the actual registered +viewer route, without guessing an operation fragment. HTTP traffic and Metrics remain application-wide. + +The collapsed advice section shows available plan provenance and interceptor binding state: **LIVE** +means bound, **INERT** means explicitly allowed to be absent, and **UNBOUND** can prevent proxy creation. +A transactional-only cache asks for recompilation. Declared settings and meter names are shown without +claiming per-route measurements. Laravel metadata is read from its public route collection with normalized +URIs and domain checks, without dispatching synthetic requests. No security authorization verdict is inferred. + +| Key | Default | Meaning | +|---|---|---| +| `firefly.admin.routes.detail` | `true` | Offer detail links; false hard-404s every `?route=` URL. | +| `firefly.admin.routes.advice` | `true` | Read and render the collapsed advice section. False omits it entirely. | diff --git a/packages/admin/tests/Route/RouteInspectorTest.php b/packages/admin/tests/Route/RouteInspectorTest.php index 905bff9f..69c940a7 100644 --- a/packages/admin/tests/Route/RouteInspectorTest.php +++ b/packages/admin/tests/Route/RouteInspectorTest.php @@ -127,13 +127,14 @@ public function resolve(array $binding, Request $request): mixed $container->instance(ProxyPlan::class, new ProxyPlan([ stdClass::class => ['proxyClass' => 'Unused', 'advice' => [ 'meter' => ['id' => 'meter', 'interceptor' => 'live.interceptor', 'descriptor' => 'Unused', 'order' => 50], + 'optional' => ['id' => 'optional', 'interceptor' => 'optional.interceptor', 'descriptor' => 'Unused', 'order' => 100, 'inert' => true], 'tx' => ['id' => 'tx', 'interceptor' => 'missing.interceptor', 'descriptor' => 'Unused', 'order' => 1000], - ], 'methods' => ['index' => [['advice' => 'meter', 'row' => ['name' => 'calls']], ['advice' => 'tx', 'row' => ['readOnly' => true]]]]], + ], 'methods' => ['index' => [['advice' => 'meter', 'row' => ['name' => 'calls']], ['advice' => 'optional', 'row' => []], ['advice' => 'tx', 'row' => ['readOnly' => true]]]]], ])); $route = new RouteDescriptor('GET', '/', stdClass::class, 'index', 200, null, []); $advice = (new RouteInspector($container))->advice($route); - expect(array_column($advice, 'id'))->toBe(['meter', 'tx']) - ->and(array_column($advice, 'state'))->toBe(['LIVE', 'UNBOUND']); + expect(array_column($advice, 'id'))->toBe(['meter', 'optional', 'tx']) + ->and(array_column($advice, 'state'))->toBe(['LIVE', 'INERT', 'UNBOUND']); }); it('derives a pattern failure sentence from the PHP name rather than the wire key', function () { diff --git a/packages/admin/tests/RouteDetailPageTest.php b/packages/admin/tests/RouteDetailPageTest.php index 0564b8a5..ffd47897 100644 --- a/packages/admin/tests/RouteDetailPageTest.php +++ b/packages/admin/tests/RouteDetailPageTest.php @@ -65,7 +65,7 @@ if ($key === 'firefly.admin.routes.detail') { $this->get('/firefly/mappings')->assertOk()->assertDontSee('data-route="', false); } -})->with([['firefly.admin.routes.detail', false], ['firefly.admin.pages.exclude', 'mappings']]); +})->with([['firefly.admin.routes.detail', false], ['firefly.admin.pages.exclude', 'mappings'], ['firefly.management.endpoint.mappings.enabled', false]]); it('keeps optional crosslinks behind destination gates and does not read detail collaborators on the listing', function () { /** @var AdminTableCapstoneTestCase $this */ diff --git a/tests/Browser/AdminTablesTest.php b/tests/Browser/AdminTablesTest.php index 905c4141..fd63b83f 100644 --- a/tests/Browser/AdminTablesTest.php +++ b/tests/Browser/AdminTablesTest.php @@ -25,7 +25,10 @@ // cell's contents reports one rectangle per line box, which is the thing being counted: // it was six, and it is one. const range = document.createRange(); - range.selectNodeContents(cell); + // The route is now an anchor with a hidden verb for its accessible name. Measure + // the visible path text, not the additional element rectangles the link introduces. + const path = cell.querySelector('.route-link'); + range.selectNodeContents(path ? [...path.childNodes].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '') : cell); return range.getClientRects().length === 1; })() JS, true) diff --git a/tests/Browser/RouteDetailTest.php b/tests/Browser/RouteDetailTest.php new file mode 100644 index 00000000..c68984dc --- /dev/null +++ b/tests/Browser/RouteDetailTest.php @@ -0,0 +1,60 @@ +extend(AdminDashboardBrowserTestCase::class); + +it('opens the route contract through a keyboard-accessible link and focuses its heading', function (): void { + visit('/firefly/mappings?q=orders&sort=path') + ->keys('a[data-route="POST /orders"]', 'Enter') + ->assertSee('Request · what the caller sends') + ->assertSee('Default success status') + ->assertScript('document.activeElement.id', 'route-detail') + ->assertScript("getComputedStyle(document.getElementById('route-detail')).outlineStyle", 'solid') + ->assertScript("(async () => (await axe.run(document.querySelector('main'), {runOnly:['color-contrast']})).violations.map(v => v.id))()", []) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'route-detail-light'); +}); + +it('keeps the complete detail and native disclosure controls usable with JavaScript disabled', function (): void { + $page = visit('/firefly/mappings', ['javaScriptEnabled' => false]); + $page + ->assertSee('Mappings'); + $page->keys('a[data-route="POST /orders"]', 'Enter'); + $page + ->assertSee('Request body') + ->keys('Dispatch order and inspection limits', 'Enter') + ->assertSee('Security authorization is not inferred') + ->keys('.route-back', 'Enter') + ->assertSee('Mappings'); +}); + +it('contains the wide binding table inside its scrollport on a phone', function (): void { + $page = visit('/firefly/mappings?route=POST%20%2Forders')->on()->mobile(); + $page + ->assertSee('Request body') + ->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true) + ->assertScript("document.querySelector('.route-bindings').closest('.tw').scrollWidth > document.querySelector('.route-bindings').closest('.tw').clientWidth", true) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'route-detail-phone'); + $page->script("document.getElementById('route-request').scrollIntoView()"); + $page->screenshot(filename: 'route-detail-phone-contract'); +}); + +it('renders the route contract and failure explanations in the dark theme', function (): void { + visit('/firefly/mappings?route=POST%20%2Forders')->inDarkMode() + ->assertSee('MALFORMED_BODY') + ->assertScript("(async () => (await axe.run(document.querySelector('main'), {runOnly:['color-contrast']})).violations.map(v => v.id))()", []) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'route-detail-dark'); +}); + +it('keeps the lower contract panels readable and disclosures operable', function (): void { + $page = visit('/firefly/mappings?route=POST%20%2Forders'); + $page->keys('Dispatch order and inspection limits', 'Enter') + ->assertSee('Security authorization is not inferred'); + $page->script("document.querySelector('main').scrollTop = document.querySelector('main').scrollHeight"); + $page->screenshot(filename: 'route-detail-lower')->assertNoJavaScriptErrors(); +}); From b16b6b9ede44e073e2d3f276faa8e01d7faaddf1 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 29 Sep 2026 13:35:16 -0700 Subject: [PATCH 73/78] fix(admin): keep route contract details and joins accurate Follow qualified bound and unbound configuration filters and verify the linked page actually narrows. Describe html as controller declaration metadata rather than a media-type promise, with ResponseFactory regressions for both stereotypes. Preserve encoded zero defaults, recover clipped binding values and verify the scrolled phone contract on the same browser page. --- docs/modules/admin.md | 5 +- .../admin/resources/views/mapping.blade.php | 11 +++-- packages/admin/src/Route/RouteBinding.php | 4 +- packages/admin/src/Web/AdminAction.php | 11 ++++- .../admin/tests/Route/RouteInspectorTest.php | 5 ++ packages/admin/tests/RouteDetailPageTest.php | 48 ++++++++++++++++++- tests/Browser/RouteDetailTest.php | 7 +-- 7 files changed, 77 insertions(+), 14 deletions(-) diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 9aeed7d9..527abbd7 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -508,8 +508,9 @@ can replace responses. Required body/service arguments do not imply a missing-pa DTO shapes prefer the compiled `dtos` table and fall back to legacy property names. Native disclosures keep the first two levels open, indentation is bounded, recursive references terminate, and a display budget limits very large trees. On phones the binding table scrolls horizontally inside its own region. -The success status is a default; returned responses can override it. HTML stereotypes are shown explicitly, -while older manifests with no HTML flag remain described as negotiated rather than promised JSON. +The success status is a default; returned responses can override it. HTML stereotypes are declaration metadata, not media-type guarantees. In both cases the returned value +and Accept determine the response: a Controller can return JSON data and a RestController can return HTML. +Older manifests may omit the stereotype flag. Controller siblings appear near the heading with comparison markers. Duplicate verb/path registrations are marked **Shadowed** in the list and shown in manifest order on the detail page; the last wins. diff --git a/packages/admin/resources/views/mapping.blade.php b/packages/admin/resources/views/mapping.blade.php index 496f0f8a..4447a6f6 100644 --- a/packages/admin/resources/views/mapping.blade.php +++ b/packages/admin/resources/views/mapping.blade.php @@ -30,7 +30,7 @@
    Default success status
    {{ $route->status }}
    -
    Response
    {{ $route->html ? 'HTML page' : 'Negotiated' }}
    +
    HTML stereotype
    {{ $route->html ? 'Declared' : 'No / legacy' }}
    Caller arguments
    {{ count($detail->caller) }}
    Injected arguments
    {{ count($detail->injected) }}
    Route source
    {{ $bootMode }}
    @@ -53,12 +53,12 @@
    {{ $binding->position }}${{ $binding->plan['name'] }}${{ $binding->plan['name'] }} {{ $binding->plan['kind'] }}{{ $binding->plan['key'] }}{{ $binding->plan['key'] }} {{ Format::leafOf($binding->plan['type'] ?? 'not recorded') }}{{ Format::stemOf($binding->plan['type'] ?? '') }} {{ $binding->plan['required'] ? 'yes' : 'no' }}{{ $binding->defaultLabel() }}{{ $binding->defaultLabel() }} {{ $binding->plan['valid'] ? 'yes' : 'no' }}
    + +@include('firefly-admin::_table-head', ['view' => $view, 'query' => $query]) +@foreach ($slice->rows as $row)@endforeach +
    {{ $slice->total }} beans · dependents first{{ $explorer->get('q') ? ' matching '.$explorer->get('q') : '' }}
    {{ \Firefly\Admin\Format::leafOf($row['id']) }}{{ \Firefly\Admin\Format::stemOf($row['id']) }}@if (in_array($row['id'], $exclusive, true)) · Only this module@endif @if ($row['in'] === 0 && $explorer->get('module') !== '')unused outside@endif{{ $row['kind'] }}{{ $row['in'] }}{{ $row['out'] }}
    +@include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query]) diff --git a/packages/admin/resources/views/_bx-drawing.blade.php b/packages/admin/resources/views/_bx-drawing.blade.php new file mode 100644 index 00000000..1a1105b7 --- /dev/null +++ b/packages/admin/resources/views/_bx-drawing.blade.php @@ -0,0 +1,35 @@ +@php + use Firefly\Admin\BeanGraph; + $cycleMembers = []; + foreach ($cycles as $group => $members) { + foreach ($members as $member) { $cycleMembers[$member] = $group; } + } +@endphp +
    +
    + @foreach ($focus->columns as $column){{ $column === 0 ? 'This bean' : ($column < 0 ? 'Dependents · '.abs($column).' hop'.(abs($column)>1?'s':'') : 'Dependencies · '.$column.' hop'.($column>1?'s':'')) }}@endforeach +
    +
    + + @foreach ($focus->positions as $id => $position) + @php $node = $nodes[$id]; $mod = $node['kind'] === 'module' ? substr($id, 7) : BeanGraph::moduleOf($id); $inCycle = isset($cycleMembers[$id]); @endphp + + {{ $node['label'] }}{{ $modules->nodes[$mod]['sigil'] }} + {{ $node['kind'] }} · {{ $node['in'] }} ↑ {{ $node['out'] }} ↓{{ $inCycle ? ' · cycle' : '' }} + + @endforeach +
    +
    diff --git a/packages/admin/resources/views/_pager.blade.php b/packages/admin/resources/views/_pager.blade.php index a39f34be..68e8bc7d 100644 --- a/packages/admin/resources/views/_pager.blade.php +++ b/packages/admin/resources/views/_pager.blade.php @@ -63,15 +63,15 @@ hasPrevious()) href="{{ $slice->link($slice->page - 1) }}" @endif>Previous @if ($window[0] > 1) - 1 + 1 @if ($window[0] > 2)…@endif @endif @foreach ($window as $n) - {{ $n }} + {{ $n }} @endforeach @if (end($window) < $slice->lastPage()) @if (end($window) < $slice->lastPage() - 1)…@endif - {{ number_format($slice->lastPage()) }} + {{ number_format($slice->lastPage()) }} @endif hasNext()) href="{{ $slice->link($slice->page + 1) }}" @endif>Next diff --git a/packages/admin/resources/views/_table-head.blade.php b/packages/admin/resources/views/_table-head.blade.php index 753af6ba..2df30b20 100644 --- a/packages/admin/resources/views/_table-head.blade.php +++ b/packages/admin/resources/views/_table-head.blade.php @@ -20,9 +20,9 @@ @foreach ($view->columns as $column) - + isSortable()) aria-sort="{{ $query->isSortedBy($column->key) ? ($query->direction === 'asc' ? 'ascending' : 'descending') : 'none' }}" @endif> @if ($query !== null && $column->isSortable()) - {{ $column->label }}{{ $query->indicator($column->key) }} + {{ $column->label }} @else {{ $column->label }} @endif diff --git a/packages/admin/resources/views/beans.blade.php b/packages/admin/resources/views/beans.blade.php index 06d216b1..5e5ec108 100644 --- a/packages/admin/resources/views/beans.blade.php +++ b/packages/admin/resources/views/beans.blade.php @@ -25,15 +25,15 @@ @foreach ($slice->rows as $bean) - {{ Format::leafOf($bean['class']) }} - {{ Format::stemOf($bean['class']) }} + @if ($settings->allows('graph'))@endif{{ Format::leafOf($bean['class']) }} + {{ Format::stemOf($bean['class']) }}@if ($settings->allows('graph'))@endif + {{ $bean['kind'] }} {{ $bean['stereotype'] ?: '—' }} - {{ $bean['scope'] ?: '—' }} - {{ $bean['name'] ?: '—' }} - {{-- The leaf names in the cell and the qualified ones on the title, exactly as the - Class column beside it: the searchable value is the one on hover. --}} + {{ $bean['scope'] ?: 'Not reported' }} + @if ($settings->allows('graph')){{ $bean['module'] }}@else{{ $bean['module'] }}@endif {{ $bean['interfaces'] ?: '—' }} + {{ $bean['in'] }}{{ $bean['out'] }} @endforeach diff --git a/packages/admin/resources/views/graph.blade.php b/packages/admin/resources/views/graph.blade.php index a568cbb5..3a72cbb5 100644 --- a/packages/admin/resources/views/graph.blade.php +++ b/packages/admin/resources/views/graph.blade.php @@ -1,436 +1,124 @@ @extends('firefly-admin::layout') -@section('title', 'Bean graph') +@section('title', 'Bean explorer') @section('body') - @php - use Firefly\Admin\BeanGraph; - use Firefly\Admin\Format; - - $counts = $graph->kindCounts(); - $modules = $graph->modules(); - - // A stable colour per module, assigned by position so the same application always draws the same - // picture. Hues are spread around the wheel and kept away from the semantic red/green the rest of - // the dashboard reserves for status. - $hues = [212, 265, 28, 172, 320, 45, 190, 288, 96, 240, 12, 150]; - $moduleHue = []; - foreach (array_values($modules) as $i => $module) { - $moduleHue[$module] = $hues[$i % count($hues)]; - } - - $byLevel = []; - foreach ($graph->nodes as $node) { $byLevel[$node['level']][] = $node; } - ksort($byLevel); - - // Cluster same-module nodes within a layer so related things end up adjacent rather than scattered. - foreach ($byLevel as $level => $row) { - usort($row, fn (array $a, array $b): int => [BeanGraph::moduleOf($a['id']), $a['label']] <=> [BeanGraph::moduleOf($b['id']), $b['label']]); - $byLevel[$level] = $row; - } - - // LAYOUT. A pure layered layout is wrong for this graph: dependency depth is shallow and wide, so - // most beans land on one or two levels and a stock skeleton produced a single row 54 nodes and - // 9184px across — which fit() then scaled to 11%, i.e. unreadable. Each LEVEL is therefore wrapped - // into a grid of its own, so the drawing stays a compact rectangle while arrows still read downward - // from dependents to dependencies. - $nodeW = 168; $nodeH = 40; $gapX = 14; $gapY = 20; $levelGap = 46; - $perRow = max(4, (int) ceil(sqrt(max(1, count($graph->nodes)))) + 2); - - $at = []; - $canvasW = $perRow * ($nodeW + $gapX) - $gapX + 40; - $y = 20; - - foreach ($byLevel as $row) { - $rows = array_chunk($row, $perRow); - foreach ($rows as $chunk) { - $rowW = count($chunk) * ($nodeW + $gapX) - $gapX; - $startX = ($canvasW - $rowW) / 2; - foreach (array_values($chunk) as $i => $node) { - $at[$node['id']] = ['x' => $startX + $i * ($nodeW + $gapX), 'y' => $y]; - } - $y += $nodeH + $gapY; - } - $y += $levelGap - $gapY; - } - - $canvasH = max(260, $y + 20); - - @endphp - -
    -

    Bean graph

    -

    Every bean this application wired, and what each one depends on. A constructor asks for a - type, so an edge through an interface is drawn to the bean that implements it and - labelled with the interface.

    -
    - -
    -
    Beans
    {{ count($graph->nodes) }}
    -
    Components
    {{ $counts[BeanGraph::KIND_COMPONENT] }}
    -
    #[Bean] products
    {{ $counts[BeanGraph::KIND_BEAN] }}
    -
    Config DTOs
    {{ $counts[BeanGraph::KIND_CONFIG] }}
    -
    Relations
    {{ count($graph->edges) }}
    -
    Layers
    {{ count($byLevel) }}
    -
    -
    Cycles
    -
    @if ($graph->cycles === [])0 @else{{ count($graph->cycles) }}@endif
    + @php use Firefly\Admin\BeanGraph; use Firefly\Admin\Format; @endphp +

    Bean explorer

    Find a bean, follow its dependencies, and see what each module brings in.

    + +

    {{ count($graph->nodes) }} beans · {{ count($graph->edges) }} relations · {{ count($modules->nodes) }} modules · {{ count($cycles) }} circular components · {{ count($graph->unresolved) }} unresolved types

    + + @if ($explorer->get('bean') !== '' && $picked === null) +

    Bean not found

    The selected bean is not in this process's current catalogue. Search again or open the full catalogue.

    + @elseif ($picked !== null) + @php $module = BeanGraph::moduleOf($picked['id']); @endphp +
    +

    {{ $picked['label'] }}

    {{ $picked['id'] }}

    +

    {{ $picked['kind'] }} · {{ $picked['stereotype'] ?: 'No stereotype reported' }} · Scope: {{ $picked['scope'] ?: 'Not reported' }} · {{ $module }}

    + @if ($picked['detail'])

    Declared by: {{ $picked['detail'] }}

    @endif + @foreach ($conditions as $condition)

    {{ $condition['outcome'] }} · {{ $condition['class'] }} · {{ $condition['condition'] }}

    @endforeach + @if ($settings->allows('beans'))See it in the container →@endif
    -
    - - @if ($graph->cycles !== []) -
    - @include('firefly-admin::_panel-head', ['title' => 'Circular dependencies', 'count' => count($graph->cycles)]) -
    - - - - @foreach ($graph->cycles as $cycle) - - - - + @if ($focus !== null) +
    +
    Skip the drawing — go to the lists + Hops:@foreach ([1,2,3,4] as $depth)depth) aria-current="true" @endif href="{{ $explorer->url(['depth' => $depth]) }}">{{ $depth }}@endforeach + @foreach (['both' => 'Both directions', 'in' => 'Dependents', 'out' => 'Dependencies'] as $dir => $label)direction) aria-current="true" @endif href="{{ $explorer->url(['dir' => $dir]) }}">{{ $label }}@endforeach +
    +

    Around {{ $picked['label'] }} · {{ count($focus->positions) }} beans drawn

    + @include('firefly-admin::_bx-drawing') +
    +

    Arrows point to dependencies. Dashed arrows indicate factory production. All direct relations, including same-column and cyclic edges, appear in the lists.

    + @foreach ($focus->overflow as $overflow) +

    +{{ $overflow['count'] }} more {{ $overflow['direction'] === 'in' ? 'dependents of' : 'dependencies of' }} {{ Format::shortClass($overflow['source']) }}

    @endforeach -
    -
    BeanDepends on
    {{ Format::shortClass($cycle['from']) }}{{ Format::shortClass($cycle['to']) }}
    + @foreach ($focus->paths as $path)

    Reached from an entry point: @foreach (array_slice($path, 0, $settings->graph->maxRows) as $id){{ Format::shortClass($id) }}@if (!$loop->last) → @endif @endforeach @if (count($path) > $settings->graph->maxRows) … {{ count($path) - $settings->graph->maxRows }} further hops; complete chain below@endif

    @endforeach + @if ($focus->paths === [])

    No entry-point chain shown. The bean may belong to a cycle, or path display is disabled.

    @endif +
    -

    The container has no cycle detection, so a - cycle among eager singletons exhausts memory at boot rather than reporting itself. Break one of - these edges — usually by depending on an interface and letting the other side provide it.

    + @endif +

    All direct relations of {{ Format::shortClass($relationSource) }}

    + @foreach ($relations as $side => $relation) +
    + @include('firefly-admin::_panel-head', ['title' => $relation['title'], 'count' => $relation['slice']->total, 'query' => $relation['query'], 'placeholder' => 'Search these relations']) +
    + @include('firefly-admin::_table-head', ['view' => $relation['view'], 'query' => $relation['query']]) + @foreach ($relation['slice']->rows as $row)@endforeach +
    {{ $relation['title'] }} · {{ $relation['slice']->total }} relations{{ $relation['query']->search ? ' matching '.$relation['query']->search : '' }}
    {{ Format::leafOf($row['id']) }}{{ Format::stemOf($row['id']) }}{{ Format::leafOf($row['via']) ?: 'Direct' }}{{ Format::stemOf($row['via']) }}{{ $row['type'] }}
    + @include('firefly-admin::_pager', ['slice' => $relation['slice'], 'query' => $relation['query']]) +
    + @endforeach +
    + @elseif ($explorer->get('q') !== '' || $explorer->get('module') !== '') +

    {{ $explorer->get('module') ?: 'Search results' }}

    + @if ($explorer->get('module') !== '')

    {{ count($exclusive) }} beans reachable only from this module. A bean with no dependents is marked unused outside.

    @endif +
    + @if ($explorer->get('module') !== '' && $modulePicked !== null && $moduleFocus !== null) +

    Module neighbourhood around {{ $modulePicked['label'] }}

    + @include('firefly-admin::_bx-drawing', ['graph' => $moduleGraph, 'nodes' => array_column($moduleGraph->nodes, null, 'id'), 'picked' => $modulePicked, 'focus' => $moduleFocus]) +

    Foreign beans are grouped into module ports. This preview uses the focus budget; the catalogue below lists every bean in the selected module.

    + @endif + @include('firefly-admin::_bx-beans') +
    + @else +

    Start here

    + @foreach (['Entry points' => $roots, 'Most depended on' => $starters] as $title => $entries)

    {{ $title }}

      @foreach ($entries as $node)
    • {{ $node['label'] }} · {{ $node['in'] }} dependents · {{ $node['out'] }} dependencies
    • @endforeach
    @if ($entries === [])

    No beans registered.

    @endif
    @endforeach
    @endif -
    -
    -

    Wiring

    - - - - -
    - - @if ($graph->nodes === []) - @include('firefly-admin::_empty', [ - 'title' => 'No beans to graph', - 'body' => 'Check firefly.scan.paths points at your application namespace.', - ]) - @elseif (count($graph->nodes) > $settings->graphMaxNodes) - @include('firefly-admin::_empty', [ - 'title' => 'Too many beans to draw at once', - 'body' => 'This application has '.count($graph->nodes).' beans. A diagram past '.$settings->graphMaxNodes.' nodes is a hairball rather than something you can read, so the relations are listed below instead. Raise firefly.admin.graph.max-nodes to draw it anyway.', - ]) - @else -
    - @foreach ($modules as $module) - - @endforeach -
    - -
    -
    - - - - - - - - - @foreach ($graph->edges as $edge) - @continue (! isset($at[$edge['from']], $at[$edge['to']])) - @php - $a = $at[$edge['from']]; $b = $at[$edge['to']]; - $x1 = $a['x'] + $nodeW / 2; $y1 = $a['y'] + $nodeH; - $x2 = $b['x'] + $nodeW / 2; $y2 = $b['y']; - $mid = ($y1 + $y2) / 2; - @endphp - - {{ Format::shortClass($edge['from']) }} → {{ Format::shortClass($edge['to']) }}{{ $edge['via'] !== null ? ' (via '.Format::shortClass($edge['via']).')' : '' }} - - @endforeach - - - @foreach ($graph->nodes as $node) - @php - $pos = $at[$node['id']]; - $module = BeanGraph::moduleOf($node['id']); - @endphp - - {{ $node['id'] }}{{ $node['detail'] !== '' ? ' — '.$node['detail'] : '' }} - - - {{ \Illuminate\Support\Str::limit($node['label'], 22) }} - {{ $node['kind'] }} · {{ $node['in'] }}↑ {{ $node['out'] }}↓ - - @endforeach - - - -
    Drag to pan · scroll to zoom · click a bean to focus it
    + @if ($picked === null && $explorer->get('q') === '') +

    Modules

    + @if (count($modules->nodes) >= 3 && count($modules->nodes) <= $settings->graph->moduleMaxNodes) + - - -
    - @endif -
    - -
    - @include('firefly-admin::_panel-head', [ - 'title' => 'Relations', 'count' => count($graph->edges), - 'filter' => 'edges-body', 'placeholder' => 'Filter relations…', - ]) - @if ($graph->edges === []) - @include('firefly-admin::_empty', [ - 'title' => 'No relations found', - 'body' => 'Every bean here is built without depending on another. Constructor parameters typed as scalars are configuration, not wiring, and are deliberately not edges.', - ]) - @else -
    - - - - @foreach ($graph->edges as $edge) - - - - - - - @endforeach - -
    BeanDepends onWired by
    {{ Format::shortClass($edge['from']) }}{{ rtrim(Format::namespaceOf($edge['from']), '\\') }}{{ $edge['type'] === 'produces' ? 'produces' : '→' }}{{ Format::shortClass($edge['to']) }}{{ rtrim(Format::namespaceOf($edge['to']), '\\') }}{{ $edge['via'] !== null ? Format::shortClass($edge['via']) : '—' }}
    -
    - @endif -
    - + @else +

    {{ count($modules->nodes) < 3 ? 'Open a module to explore its beans.' : 'The module map exceeds its configured budget; every module remains available below.' }}

    + @endif +
    + @include('firefly-admin::_table-head', ['view' => $moduleView, 'query' => $moduleQuery]) + @foreach ($moduleSlice->rows as $row)@endforeach +
    {{ $row['sigil'] }} · {{ $row['id'] }}{{ $row['count'] }}{{ $row['cycle'] ?: '—' }}
    + @include('firefly-admin::_pager', ['slice' => $moduleSlice, 'query' => $moduleQuery]) +

    Module coupling

    Weight counts resolved dependency relations; Beans counts distinct targets; Via counts distinct interfaces; Concrete counts direct relations.

    + {{ $explorer->get('produces') === '1' ? 'Exclude factory production' : 'Include factory production' }} +
    + @include('firefly-admin::_table-head', ['view' => $couplingView, 'query' => $couplingQuery]) + @foreach ($couplingSlice->rows as $edge)@endforeach +
    Module coupling
    {{ $edge['from'] }}{{ $edge['to'] }}{{ $edge['weight'] }}{{ $edge['beans'] }}{{ $edge['via'] }}{{ $edge['concrete'] }}
    + @include('firefly-admin::_pager', ['slice' => $couplingSlice, 'query' => $couplingQuery]) +

    {{ count($modules->cycles) }} module cycle components; membership is shown in the module list.

    +
    + @endif + @if ($pathSlice->total > 0) +
    + @include('firefly-admin::_panel-head', ['title' => 'Complete entry-point chains', 'count' => $pathSlice->total, 'query' => $pathQuery, 'placeholder' => 'Search chain members']) +
    @include('firefly-admin::_table-head', ['view' => $pathView, 'query' => $pathQuery]) + @foreach ($pathSlice->rows as $row)@endforeach +
    {{ $row['chain'] }}{{ $row['hop'] }}{{ $row['id'] }}
    + @include('firefly-admin::_pager', ['slice' => $pathSlice, 'query' => $pathQuery]) +
    + @endif + @if ($cycles !== []) +
    + @include('firefly-admin::_panel-head', ['title' => 'Circular dependencies · wiring graph components', 'count' => $cycleSlice->total, 'query' => $cycleQuery, 'placeholder' => 'Search cycle members']) +

    These are complete strongly connected components of the wiring graph, including factory production; membership does not necessarily imply a runtime constructor cycle.

    +
    @include('firefly-admin::_table-head', ['view' => $cycleView, 'query' => $cycleQuery]) + @foreach ($cycleSlice->rows as $row)@endforeach +
    {{ $row['component'] + 1 }}{{ $row['id'] }}
    + @include('firefly-admin::_pager', ['slice' => $cycleSlice, 'query' => $cycleQuery]) +
    + @endif @if ($graph->unresolved !== [])
    - @include('firefly-admin::_panel-head', ['title' => 'Provided outside the container', 'count' => count($graph->unresolved)]) -
    - @foreach ($graph->unresolved as $type) - {{ Format::shortClass($type) }} - @endforeach -
    -

    These constructor types are satisfied by a - Laravel container binding rather than a scanned bean — the request, the config repository, a - database connection — so they are not drawn as nodes.

    + @include('firefly-admin::_panel-head', ['title' => 'Unresolved types', 'count' => $unresolvedSlice->total, 'query' => $unresolvedQuery, 'placeholder' => 'Search unresolved types']) +

    Outside the catalogue or ambiguous: the published metadata cannot identify the binding or choose between competing candidates.

    +
    @include('firefly-admin::_table-head', ['view' => $unresolvedView, 'query' => $unresolvedQuery]) + @foreach ($unresolvedSlice->rows as $row)@endforeach +
    {{ $row['id'] }}
    + @include('firefly-admin::_pager', ['slice' => $unresolvedSlice, 'query' => $unresolvedQuery])
    @endif - - @push('scripts') - - @endpush @endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index e640a893..9af67926 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -513,6 +513,31 @@ } .act:hover{border-color:var(--accent);color:var(--accent)} + .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} + :root{--mod-l:38%} + html[data-theme="dark"]{--mod-l:62%} + @media(prefers-color-scheme:dark){html[data-theme="auto"]{--mod-l:62%}} + .bx-module-map{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:16px 0} + .bx-module{display:flex;flex-direction:column;gap:7px;padding:12px;border:1px solid var(--line);border-left:4px solid hsl(var(--hue) 62% var(--mod-l));border-radius:5px;color:var(--ink);overflow-wrap:break-word} + .bx-search,.bx-controls,.bx-module-list{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:12px 0} + .bx-search input{min-width:240px;padding:8px;color:var(--ink);background:var(--panel);border:1px solid var(--line-2)} + .bx-details,.bx-controls,.bx-caption{padding:14px} + .bx-details li{margin:8px 0} + .bx{overflow:auto;padding:10px 14px;background:var(--panel-2)} + .bx-heads{display:grid;margin-bottom:12px;color:var(--ink-2);font-size:11px;font-weight:600} + .bx-drawing{position:relative;margin:4px 0} + .bx-edges{position:absolute;inset:0;overflow:visible;pointer-events:none} + .bx-edge{fill:none;stroke:var(--ink-3);stroke-width:1.2} + .bx-edge.cycle{stroke-dasharray:6 3;stroke-width:2} + .bx-edge.produces{stroke-dasharray:5 4} + .bx-node{position:absolute;display:block;width:176px;height:38px;border:1px solid var(--line-2);border-left:3px solid hsl(var(--hue) 62% var(--mod-l));border-radius:5px;padding:3px 7px;color:var(--ink);background:var(--panel);text-decoration:none;font-size:12px;line-height:14px} + .bx-name{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:25px} + .bx-sigil{position:absolute;right:5px;top:3px;font-size:11px;color:var(--ink-2)} + .bx-sub{font-size:11px;color:var(--ink-2)} + .bx-controls .act[aria-current="true"]{font-weight:700;border:2px solid var(--ink);text-decoration:underline} + .bx-node.is-focus{border:2px solid var(--accent)} + .bx-node:focus-visible,.bx:focus-visible{outline:3px solid var(--accent);outline-offset:3px} + .bx-node:hover{background:var(--panel-2)} /* ── bean graph ──────────────────────────────────────────────────── */ .legend{display:flex;flex-wrap:wrap;gap:6px;padding:11px 14px;border-bottom:1px solid var(--line);background:var(--panel-2)} .mod{ @@ -520,61 +545,12 @@ border:1px solid var(--line-2);background:transparent;color:var(--ink-2);cursor:pointer; font-family:var(--mono);font-size:11px; } - .mod i{width:8px;height:8px;border-radius:2px;background:hsl(var(--hue) 62% 46%);flex:none} + .mod i{width:8px;height:8px;border-radius:2px;background:hsl(var(--hue) 62% var(--mod-l));flex:none} .mod:hover{border-color:var(--ink-3);color:var(--ink)} .mod[aria-pressed="false"]{opacity:.42;text-decoration:line-through} - .graph{display:grid;grid-template-columns:minmax(0,1fr) 268px} - @media(max-width:1100px){.graph{grid-template-columns:1fr}} - .graph .canvas{position:relative;height:min(72vh,720px);overflow:hidden;background:var(--panel-2);cursor:grab;touch-action:none} - .graph .canvas.grabbing{cursor:grabbing} - .graph .canvas svg{width:100%;height:100%;display:block} - .hint-bar{ - position:absolute;left:10px;bottom:8px;font-size:11px;color:var(--ink-3); - background:color-mix(in srgb, var(--panel) 84%, transparent);padding:3px 8px;border-radius:6px; - pointer-events:none; - } - .inspect{border-left:1px solid var(--line);padding:12px 14px;overflow:hidden auto;height:min(72vh,720px);background:var(--panel);min-width:0} - @media(max-width:1100px){.inspect{border-left:0;border-top:1px solid var(--line);height:auto;max-height:320px}} - .inspect .blank{color:var(--ink-3);font-size:13px;padding:18px 0} - .inspect .who{margin-bottom:12px} - .inspect .who strong{display:block;font-size:14.5px} - .inspect .who code{display:block;margin-top:4px;font-size:10.5px;overflow-wrap:anywhere;background:none;border:0;padding:0;color:var(--ink-3)} - .inspect h4{margin:12px 0 5px;font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3)} - .inspect h4 span{color:var(--ink-2);letter-spacing:0} - .inspect ul{list-style:none;margin:0;padding:0} - .inspect li button{ - display:block;width:100%;text-align:left;background:none;border:0;padding:3px 0;cursor:pointer; - font-family:var(--mono);font-size:11.5px;color:var(--accent); - overflow-wrap:anywhere;line-height:1.4; - } - .inspect li button:hover{text-decoration:underline} - .inspect .none{margin:0;font-size:12.5px;color:var(--ink-3)} .canvas{overflow:auto;padding:14px;background:var(--panel-2);max-height:70vh} .canvas svg{display:block;margin-inline:auto} - .edges .edge{fill:none;stroke:var(--line-2);stroke-width:1.3;color:var(--line-2);transition:stroke .12s,opacity .12s} - .edges .edge.via{stroke-dasharray:4 3} - /* A `produces` edge is structure, not a dependency the author wrote — drawn quieter so the wiring - the reader came to see stays the loudest thing on the canvas. */ - .edges .edge.produces{stroke-dasharray:1 4;opacity:.55} - .edges .edge.lit{stroke:var(--accent);color:var(--accent);stroke-width:2;opacity:1} - .edges .edge.dimmed{opacity:.08} - .edges .edge.off{display:none} - - .nodes .node{cursor:pointer} - .nodes .node rect{fill:var(--panel);stroke:var(--line-2);stroke-width:1.1;transition:stroke .12s,fill .12s} - .nodes .node .stripe{fill:hsl(var(--hue) 62% 46%);stroke:none} - .nodes .node text{font-family:var(--mono);font-size:11px;fill:var(--ink);pointer-events:none} - .nodes .node text.sub{font-size:9.5px;fill:var(--ink-3)} - /* A #[Bean] product is a value a factory returns, not a class the scanner found — dashed says so. */ - .nodes .node.k-bean rect{stroke-dasharray:3 2} - .nodes .node.k-config rect{fill:var(--hover)} - .nodes .node:hover rect,.nodes .node.lit rect{stroke:var(--accent);fill:var(--accent-soft)} - .nodes .node.picked rect{stroke:var(--accent);stroke-width:2} - .nodes .node.dimmed{opacity:.22} - .nodes .node.off{display:none} - .nodes .node:focus-visible rect{stroke:var(--accent);stroke-width:2} - /* ── data browser ────────────────────────────────────────────────── */ .pager{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 14px; border-top:1px solid var(--line);font-size:12.5px;color:var(--ink-2)} @@ -744,75 +720,6 @@ function start() { })(); @endif - // Bean graph: hovering or clicking a node lights its edges and both endpoints, and dims everything - // else. Reading a dependency diagram is asking "what touches THIS", and a static picture cannot - // answer that once there is more than a handful of nodes. - (function () { - var svg = document.querySelector('.canvas svg'); - if (!svg) { return; } - - var nodes = svg.querySelectorAll('.node'); - var edges = svg.querySelectorAll('.edge'); - - function clear() { - nodes.forEach(function (n) { n.classList.remove('lit', 'dimmed'); }); - edges.forEach(function (e) { e.classList.remove('lit', 'dimmed'); }); - } - - function focus(id) { - var touched = {}; - touched[id] = true; - edges.forEach(function (edge) { - var from = edge.getAttribute('data-from'), to = edge.getAttribute('data-to'); - if (from === id || to === id) { - edge.classList.add('lit'); - edge.classList.remove('dimmed'); - touched[from] = true; - touched[to] = true; - } else { - edge.classList.add('dimmed'); - edge.classList.remove('lit'); - } - }); - nodes.forEach(function (node) { - var hit = touched[node.getAttribute('data-id')]; - node.classList.toggle('lit', !!hit); - node.classList.toggle('dimmed', !hit); - }); - } - - var pinned = null; - nodes.forEach(function (node) { - var id = node.getAttribute('data-id'); - node.addEventListener('mouseenter', function () { if (!pinned) { focus(id); } }); - node.addEventListener('mouseleave', function () { if (!pinned) { clear(); } }); - node.addEventListener('click', function () { - pinned = pinned === id ? null : id; - pinned ? focus(pinned) : clear(); - }); - }); - svg.addEventListener('click', function (event) { - if (event.target === svg) { pinned = null; clear(); } - }); - - // The graph filter highlights rather than hides: removing a node would silently remove its - // edges too, and an edge to something you cannot see is worse than no filter at all. - var find = document.querySelector('[data-filter="graph-body"]'); - if (find) { - find.addEventListener('input', function () { - var needle = find.value.toLowerCase(); - pinned = null; - if (needle === '') { clear(); return; } - nodes.forEach(function (node) { - var hit = (node.getAttribute('data-search') || '').indexOf(needle) !== -1; - node.classList.toggle('lit', hit); - node.classList.toggle('dimmed', !hit); - }); - edges.forEach(function (e) { e.classList.add('dimmed'); e.classList.remove('lit'); }); - }); - } - })(); - // "/" focuses the first filter on the page — the shortcut every log and table UI uses. document.addEventListener('keydown', function (event) { if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) { return; } diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php index aa59d713..5d0357e5 100644 --- a/packages/admin/src/AdminSettings.php +++ b/packages/admin/src/AdminSettings.php @@ -35,6 +35,7 @@ public function __construct( public int $graphMaxNodes = 220, public array $excludedPages = [], public TableSettings $table = new TableSettings, + public BeanGraphSettings $graph = new BeanGraphSettings, ) {} public static function fromConfig(Config $config): self @@ -49,15 +50,14 @@ public static function fromConfig(Config $config): self // never finish and the dashboard would hammer the application it is supposed to be observing. refreshSeconds: max(2, $config->int('firefly.admin.refresh-seconds', 10)), theme: self::theme($config->string('firefly.admin.theme', 'auto')), - // Past this, a dependency diagram is a hairball rather than something anyone can read, so the - // graph page lists the relations instead of drawing them. Configurable because "unreadable" - // depends on the screen and the application. + // Deprecated compatibility value; bounded focus views no longer read this ceiling. graphMaxNodes: max(0, $config->int('firefly.admin.graph.max-nodes', 220)), excludedPages: self::csv($config->string('firefly.admin.pages.exclude', '')), // Every listing's paging, sorting and spacing. On AdminSettings rather than resolved separately // because a Blade view reaches exactly one settings object, and a second one would mean every // view that draws a table taking a second parameter through render(). table: TableSettings::fromConfig($config), + graph: BeanGraphSettings::fromConfig($config), ); } diff --git a/packages/admin/src/BeanGraph.php b/packages/admin/src/BeanGraph.php index cbfcf680..43c53611 100644 --- a/packages/admin/src/BeanGraph.php +++ b/packages/admin/src/BeanGraph.php @@ -26,10 +26,8 @@ * the interface in `via` so the reader sees the indirection rather than being quietly shown something they * did not write. * - * Layering is a longest-path assignment over the resolved edges, so a node sits below everything that - * depends on it and arrows read downward. The walk carries its own visited set, so a cycle terminates and - * the edge that closed it is REPORTED — which matters, because the container has no cycle detection and a - * cycle among eager singletons exhausts memory at boot. + * Iterative Tarjan analysis condenses strongly connected components before assigning longest-path levels. + * Cycle membership is a wiring fact; production edges mean it need not be a runtime constructor cycle. */ final class BeanGraph { @@ -65,7 +63,7 @@ public function __construct( public static function build(array $beans, array $configProperties = []): self { $index = new BeanGraphIndex; - $index->countProducers($beans); + $index->countProducers($beans, $configProperties); foreach ($beans as $row) { if (! is_array($row) || ! is_string($row['class'] ?? null)) { @@ -85,8 +83,8 @@ public static function build(array $beans, array $configProperties = []): self $degree = []; foreach ($edges as $edge) { - $degree[$edge['from']]['out'] = ($degree[$edge['from']]['out'] ?? 0) + 1; - $degree[$edge['to']]['in'] = ($degree[$edge['to']]['in'] ?? 0) + 1; + $degree[$edge['from']]['out'][$edge['to']] = true; + $degree[$edge['to']]['in'][$edge['from']] = true; } $nodes = []; @@ -94,8 +92,8 @@ public static function build(array $beans, array $configProperties = []): self $nodes[] = [ ...$node, 'level' => $levels[$id] ?? 0, - 'in' => $degree[$id]['in'] ?? 0, - 'out' => $degree[$id]['out'] ?? 0, + 'in' => count($degree[$id]['in'] ?? []), + 'out' => count($degree[$id]['out'] ?? []), ]; } @@ -160,9 +158,7 @@ public static function moduleOf(string $id): string } /** - * Longest-path layering, so a node always sits below everything that depends on it. Depth is memoised and - * the walk carries a visited set, so a cycle terminates instead of recursing forever — and the edge that - * closed it is reported. + * Longest-path levels on the component DAG, with every internal cyclic edge retained for compatibility. * * * @param list $ids diff --git a/packages/admin/src/BeanGraphIndex.php b/packages/admin/src/BeanGraphIndex.php index 604240cc..c1f801cf 100644 --- a/packages/admin/src/BeanGraphIndex.php +++ b/packages/admin/src/BeanGraphIndex.php @@ -25,21 +25,38 @@ final class BeanGraphIndex /** @var array interface or produced type => the node id that satisfies it */ private array $satisfiedBy = []; + /** @var array */ + private array $ambiguous = []; + /** @var list, type: string}> */ private array $pending = []; /** @var array how many factory methods produce each type */ private array $producerCount = []; - /** @param array $rows */ - public function countProducers(array $rows): void + /** + * @param array $rows + * @param array $configProperties + */ + public function countProducers(array $rows, array $configProperties = []): void { + $classes = []; + foreach ($rows as $row) { + if (is_array($row) && is_string($row['class'] ?? null)) { + $classes[$row['class']] = true; + } + } + foreach ($configProperties as $class => $row) { + if (is_array($row) && ($row['bound'] ?? true) !== false && is_string($row['class'] ?? $class)) { + $classes[is_string($row['class'] ?? null) ? $row['class'] : (string) $class] = true; + } + } foreach ($rows as $row) { if (! is_array($row)) { continue; } foreach ($this->producers($row['produces'] ?? null) as $produced) { - $this->producerCount[$produced['type']] = ($this->producerCount[$produced['type']] ?? 0) + 1; + $this->producerCount[$produced['type']] = ($this->producerCount[$produced['type']] ?? (isset($classes[$produced['type']]) ? 1 : 0)) + 1; } } } @@ -114,11 +131,12 @@ private function addBean(string $declaring, array $produced): void 'detail' => Format::shortClass($declaring).'::'.$produced['method'].'()', ]); - // The produced type resolves to this node. With competitors, first-writer-wins gives the bare type a - // stable owner while each competitor keeps its own node — the same shape the container itself has, - // where the type key aliases the #[Primary] winner and every candidate stays reachable by name. + // Without primary/qualifier metadata the catalogue cannot identify a contested winner. if (! $contested) { $this->satisfy($produced['type'], $id); + } else { + $this->ambiguous[$produced['type']] = true; + unset($this->satisfiedBy[$produced['type']]); } $this->pending[] = ['from' => $declaring, 'dependencies' => [$id], 'type' => BeanGraph::EDGE_PRODUCES]; @@ -162,7 +180,7 @@ public function edges(): array continue; } - $key = $entry['from'].'>'.$target.'>'.$entry['type']; + $key = $entry['from']."\0".$target."\0".$entry['type']."\0".$dependency; if (isset($seen[$key])) { continue; } @@ -182,13 +200,26 @@ public function edges(): array private function resolve(string $type): ?string { + if (isset($this->ambiguous[$type])) { + return null; + } + return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null); } - /** First writer wins, so the same application always draws the same graph. */ + /** The projection lacks qualifiers and primary metadata: never invent a winner. */ private function satisfy(string $type, string $nodeId): void { - $this->satisfiedBy[$type] ??= $nodeId; + if (isset($this->ambiguous[$type])) { + return; + } + if (isset($this->satisfiedBy[$type]) && $this->satisfiedBy[$type] !== $nodeId) { + unset($this->satisfiedBy[$type]); + $this->ambiguous[$type] = true; + + return; + } + $this->satisfiedBy[$type] = $nodeId; } /** diff --git a/packages/admin/src/BeanModules.php b/packages/admin/src/BeanModules.php index b66d223c..1154914c 100644 --- a/packages/admin/src/BeanModules.php +++ b/packages/admin/src/BeanModules.php @@ -56,6 +56,44 @@ public static function fromGraph(BeanGraph $graph, bool $produces = false): self return new self($nodes, $edges, array_values(array_filter(GraphComponents::of(array_keys($nodes), $out)->groups, static fn (array $group): bool => count($group) > 1))); } + /** A module's own beans plus one boundary port per foreign module. */ + public static function scope(BeanGraph $graph, string $module): BeanGraph + { + $nodes = []; + foreach ($graph->nodes as $node) { + if (BeanGraph::moduleOf($node['id']) === $module) { + $nodes[$node['id']] = $node; + } + } + $edges = []; + foreach ($graph->edges as $edge) { + $from = BeanGraph::moduleOf($edge['from']); + $to = BeanGraph::moduleOf($edge['to']); + if ($from !== $module && $to !== $module) { + continue; + } + foreach (['from' => $from, 'to' => $to] as $side => $foreign) { + if ($foreign !== $module) { + $id = 'module:'.$foreign; + $edge[$side] = $id; + $nodes[$id] ??= ['id' => $id, 'label' => $foreign, 'namespace' => $foreign, 'kind' => 'module', 'stereotype' => '', 'scope' => '', 'detail' => 'Boundary port', 'level' => 0, 'in' => 0, 'out' => 0]; + } + } + $edges[$edge['from']."\0".$edge['to']."\0".$edge['type']] = $edge; + } + + foreach ($edges as $edge) { + if ($nodes[$edge['from']]['kind'] === 'module') { + $nodes[$edge['from']]['out']++; + } + if ($nodes[$edge['to']]['kind'] === 'module') { + $nodes[$edge['to']]['in']++; + } + } + + return new BeanGraph(array_values($nodes), array_values($edges), [], []); + } + /** * @return list */ public static function exclusive(BeanGraph $graph, string $module): array diff --git a/packages/admin/src/BeanNeighbourhood.php b/packages/admin/src/BeanNeighbourhood.php index 12547d41..f23644ce 100644 --- a/packages/admin/src/BeanNeighbourhood.php +++ b/packages/admin/src/BeanNeighbourhood.php @@ -113,6 +113,10 @@ public static function around(BeanGraph $graph, string $focus, int $depth, strin * @return list> */ private static function rootPaths(string $focus, array $in, int $limit): array { + foreach ($in as &$parents) { + sort($parents, SORT_STRING); + } + unset($parents); $queue = [$focus]; $toward = [$focus => null]; $paths = []; diff --git a/packages/admin/src/ExplorerQuery.php b/packages/admin/src/ExplorerQuery.php new file mode 100644 index 00000000..304b0cc1 --- /dev/null +++ b/packages/admin/src/ExplorerQuery.php @@ -0,0 +1,60 @@ + $parameters */ + public function __construct(public string $path, public array $parameters, public int $depth, public string $direction) {} + + public static function fromRequest(Request $request, string $path, BeanGraphSettings $settings): self + { + $parameters = []; + foreach ($request->query() as $key => $value) { + if (is_string($key) && is_string($value) && strlen($value) <= 1024) { + $parameters[$key] = $value; + } + } + $raw = $parameters['depth'] ?? ''; + $depth = ctype_digit($raw) ? min(4, max(1, (int) $raw)) : $settings->depth; + $direction = in_array($parameters['dir'] ?? '', ['in', 'out', 'both'], true) ? $parameters['dir'] : 'both'; + $parameters['depth'] = (string) $depth; + $parameters['dir'] = $direction; + + return new self($path, $parameters, $depth, $direction); + } + + public function get(string $key): string + { + return $this->parameters[$key] ?? ''; + } + + /** @param array $changes */ + public function url(array $changes = []): string + { + $parameters = array_filter([...$this->parameters, ...$changes], static fn (string|int|null $value): bool => $value !== null && $value !== ''); + + return $this->path.($parameters === [] ? '' : '?'.http_build_query($parameters)); + } + + /** Parameters owned by other controls only. + * @return array */ + public function carried(string $qualifier): array + { + $parameters = $this->parameters; + foreach (['page', 'size', 'sort', 'dir', 'q'] as $key) { + unset($parameters[$qualifier.'_'.$key]); + } + + return $parameters; + } + + public function bean(string $id): string + { + return $this->url(['bean' => $id, 'module' => null, 'q' => null, 'neighbor' => null, 'rel' => null, 'in_page' => null, 'out_page' => null]); + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 41ed8852..14e15b4f 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -9,6 +9,8 @@ use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; use Firefly\Admin\BeanGraph; +use Firefly\Admin\BeanModules; +use Firefly\Admin\BeanNeighbourhood; use Firefly\Admin\Data\ConnectionWizard; use Firefly\Admin\Data\DataBrowser; use Firefly\Admin\Data\DataColumn; @@ -16,6 +18,7 @@ use Firefly\Admin\Data\DataListing; use Firefly\Admin\Data\DataMap; use Firefly\Admin\Data\DatasourceReport; +use Firefly\Admin\ExplorerQuery; use Firefly\Admin\Format; use Firefly\Admin\RowComparator; use Firefly\Admin\Settings\FeatureToggle; @@ -553,29 +556,8 @@ private function data(Request $request, string $slug): array defaultSort: 'timestamp', defaultDirection: 'desc', ), - 'beans' => $this->listing( - $request, - 'beans', - $this->beanRows(), - TableView::of( - TableColumn::qualified('class', 'Class', weight: 5), - TableColumn::token('stereotype', 'Stereotype', weight: 2), - TableColumn::token('scope', 'Scope', weight: 1.5), - TableColumn::token('name', 'Name', weight: 2), - TableColumn::text('interfaces', 'Implements', weight: 3, sortable: true), - ), - // `interfacesQualified` is searched but has no column: it is the same list the Implements - // column draws, spelled out, and the cell carries it on its `title`. See beanRows(). - ['class', 'stereotype', 'scope', 'name', 'interfaces', 'interfacesQualified'], - 'class', - ), - // #[ConfigProperties] DTOs are bound and injectable but are neither scanned as components nor - // produced by a factory, so the beans catalogue alone cannot see them — they arrived as - // unresolved dependencies instead of as the beans they are. - 'graph' => ['graph' => BeanGraph::build( - $this->listOf('beans', 'beans'), - $this->subArray($this->payload('configprops'), 'beans'), - )], + 'beans' => $this->beansPage($request), + 'graph' => $this->graphPage($request), 'conditions' => $this->conditionsPage($request), 'mappings' => $this->listing( $request, @@ -744,6 +726,149 @@ private function listing( ]; } + private function beanGraph(): BeanGraph + { + return BeanGraph::build($this->listOf('beans', 'beans'), $this->subArray($this->payload('configprops'), 'beans')); + } + + /** @return array */ + private function beansPage(Request $request): array + { + $graph = $this->beanGraph(); + $rows = []; + $catalogue = array_column($this->beanRows(), null, 'class'); + foreach ($graph->nodes as $node) { + $rows[] = [...($catalogue[$node['id']] ?? ['name' => '', 'interfaces' => '', 'interfacesQualified' => '']), ...$node, 'class' => $node['id'], 'module' => BeanGraph::moduleOf($node['id'])]; + } + $view = TableView::of( + TableColumn::qualified('class', 'Class', weight: 5), + TableColumn::token('kind', 'Kind', weight: 2), + TableColumn::token('stereotype', 'Stereotype', weight: 2), + TableColumn::token('scope', 'Scope', weight: 2), + TableColumn::text('module', 'Module', weight: 3, sortable: true), + TableColumn::text('interfaces', 'Implements', weight: 3, sortable: true), + TableColumn::number('in', 'Dependents', ch: 11), + TableColumn::number('out', 'Dependencies', ch: 13), + ); + $query = ListingQuery::fromRequest($request, $this->settings->table->boundedBy($this->settings->graph->beansPageSize, 500), $this->settings->url('beans'), $view->sortable(), 'in', in_array($request->query('sort'), $view->sortable(), true) && $request->query('sort') !== 'in' ? 'asc' : 'desc'); + + return ['graph' => $graph, 'view' => $view, 'query' => $query, 'slice' => InMemoryListing::page($rows, $query, ['class', 'kind', 'stereotype', 'scope', 'module', 'detail', 'name', 'interfacesQualified'], 'class')]; + } + + /** @return array */ + private function graphPage(Request $request): array + { + $graph = $this->beanGraph(); + $explorer = ExplorerQuery::fromRequest($request, $this->settings->url('graph'), $this->settings->graph); + $nodes = array_column($graph->nodes, null, 'id'); + $picked = $nodes[$explorer->get('bean')] ?? null; + $modules = BeanModules::fromGraph($graph, $explorer->get('produces') === '1'); + $focus = $picked === null ? null : BeanNeighbourhood::around($graph, $picked['id'], $explorer->depth, $explorer->direction, $this->settings->graph); + $conditions = []; + $report = $this->payload('conditions'); + $classes = $picked === null ? [] : [$picked['id']]; + foreach ($graph->edges as $edge) { + if ($picked !== null && $edge['to'] === $picked['id'] && $edge['type'] === BeanGraph::EDGE_PRODUCES) { + $classes[] = $edge['from']; + } + } + foreach (['positiveMatches' => 'Applied', 'negativeMatches' => 'Backed off'] as $key => $outcome) { + foreach ($this->subArray($report, $key) as $row) { + if (is_array($row) && is_string($row['class'] ?? null) && in_array($row['class'], $classes, true) && is_string($row['condition'] ?? null)) { + $conditions[] = ['class' => $row['class'], 'condition' => $row['condition'], 'outcome' => $outcome]; + } + } + } + $starters = $graph->nodes; + usort($starters, static fn (array $a, array $b): int => [$b['in'], $a['id']] <=> [$a['in'], $b['id']]); + $roots = array_values(array_filter($graph->nodes, static fn (array $node): bool => $node['in'] === 0)); + usort($roots, static fn (array $a, array $b): int => [$b['out'], $a['id']] <=> [$a['out'], $b['id']]); + $rows = array_values(array_filter($graph->nodes, static fn (array $node): bool => $explorer->get('module') === '' || BeanGraph::moduleOf($node['id']) === $explorer->get('module'))); + $view = TableView::of(TableColumn::qualified('id', 'Bean', weight: 5), TableColumn::token('kind', 'Kind', weight: 2), TableColumn::number('in', 'Dependents', ch: 11), TableColumn::number('out', 'Dependencies', ch: 13)); + $query = ListingQuery::fromRequest($request, $this->settings->table->boundedBy($this->settings->graph->pageSize, 500), $this->settings->url('graph'), $view->sortable(), 'in', 'desc', $explorer->carried('beans'), 'beans'); + // Search is the landing form's unqualified q; module paging uses a qualified listing. + $searchRequest = Request::create('/', 'GET', [...$request->query(), 'beans_q' => $explorer->get('q')]); + $query = ListingQuery::fromRequest($searchRequest, $query->settings, $query->path, $view->sortable(), 'in', 'desc', $explorer->carried('beans'), 'beans'); + $relations = []; + $source = $explorer->get('neighbor') ?: ($picked['id'] ?? ''); + foreach (['in' => 'Depended on by', 'out' => 'Depends on'] as $side => $title) { + $relationRows = []; + foreach ($graph->edges as $index => $edge) { + if ($edge[$side === 'in' ? 'to' : 'from'] !== $source) { + continue; + } + $id = $edge[$side === 'in' ? 'from' : 'to']; + $relationRows[] = ['id' => $id, 'via' => $edge['via'] ?? '', 'type' => $edge['type'], 'row' => $edge['from']."\0".$edge['to']."\0".$edge['type']."\0".($edge['via'] ?? '')]; + } + $relationView = TableView::of(TableColumn::qualified('id', 'Bean', weight: 5), TableColumn::qualified('via', 'Via interface', weight: 4), TableColumn::token('type', 'Relation', weight: 2)); + $relationQuery = ListingQuery::fromRequest($request, $query->settings, $query->path, $relationView->sortable(), carried: $explorer->carried($side), qualifier: $side); + $relations[$side] = ['title' => $title, 'view' => $relationView, 'query' => $relationQuery, 'slice' => InMemoryListing::page($relationRows, $relationQuery, ['id', 'via', 'type'], 'row')]; + } + + $cycleRows = []; + $cycles = $graph->components(); + foreach ($cycles as $component => $members) { + if ($explorer->get('cycle') !== '' && $explorer->get('cycle') !== (string) $component) { + continue; + } + foreach ($members as $id) { + $cycleRows[] = ['component' => $component, 'id' => $id]; + } + } + $cycleView = TableView::of(TableColumn::number('component', 'Component', ch: 11), TableColumn::qualified('id', 'Member', weight: 5)); + $cycleQuery = ListingQuery::fromRequest($request, $query->settings, $query->path, $cycleView->sortable(), carried: $explorer->carried('cycles'), qualifier: 'cycles'); + $unresolvedRows = array_map(static fn (string $id): array => ['id' => $id], $graph->unresolved); + $unresolvedView = TableView::of(TableColumn::qualified('id', 'Unresolved type', weight: 5)); + $unresolvedQuery = ListingQuery::fromRequest($request, $query->settings, $query->path, $unresolvedView->sortable(), carried: $explorer->carried('unresolved'), qualifier: 'unresolved'); + $pathRows = []; + foreach ($focus->paths ?? [] as $chain => $path) { + foreach ($path as $hop => $id) { + $pathRows[] = ['chain' => $chain + 1, 'hop' => $hop, 'id' => $id, 'row' => sprintf('%02d:%08d', $chain, $hop)]; + } + } + $pathView = TableView::of(TableColumn::number('chain', 'Chain', ch: 6), TableColumn::number('hop', 'Hop', ch: 5), TableColumn::qualified('id', 'Bean', weight: 5)); + $pathQuery = ListingQuery::fromRequest($request, $query->settings, $query->path, $pathView->sortable(), carried: $explorer->carried('paths'), qualifier: 'paths'); + $moduleGraph = BeanModules::scope($graph, $explorer->get('module')); + $moduleAnchor = $rows; + usort($moduleAnchor, static fn (array $a, array $b): int => [$b['in'] + $b['out'], $a['id']] <=> [$a['in'] + $a['out'], $b['id']]); + $modulePicked = $moduleAnchor[0] ?? null; + $moduleFocus = $modulePicked === null ? null : BeanNeighbourhood::around($moduleGraph, $modulePicked['id'], $explorer->depth, 'both', $this->settings->graph); + $couplingView = TableView::of(TableColumn::qualified('from', 'From', weight: 4), TableColumn::qualified('to', 'To', weight: 4), TableColumn::number('weight', 'Weight', ch: 7), TableColumn::number('beans', 'Beans', ch: 6), TableColumn::number('via', 'Via', ch: 5), TableColumn::number('concrete', 'Concrete', ch: 9)); + $couplingQuery = ListingQuery::fromRequest($request, $query->settings, $query->path, $couplingView->sortable(), carried: $explorer->carried('coupling'), qualifier: 'coupling'); + $couplingRows = []; + foreach ($modules->edges as $edge) { + if ($explorer->get('module') === '' || $edge['from'] === $explorer->get('module') || $edge['to'] === $explorer->get('module')) { + $couplingRows[] = [...$edge, 'row' => $edge['from']."\0".$edge['to']]; + } + } + $moduleView = TableView::of(TableColumn::qualified('id', 'Module', weight: 5), TableColumn::number('count', 'Beans', ch: 8), TableColumn::token('cycle', 'Cycle', weight: 2)); + $moduleQuery = ListingQuery::fromRequest($request, $query->settings, $query->path, $moduleView->sortable(), carried: $explorer->carried('modules'), qualifier: 'modules'); + $moduleCycles = []; + foreach ($modules->cycles as $index => $members) { + foreach ($members as $id) { + $moduleCycles[$id] = 'Cycle '.($index + 1); + } + } + $moduleRows = []; + foreach ($modules->nodes as $id => $info) { + $moduleRows[] = ['id' => $id, 'cycle' => $moduleCycles[$id] ?? '', ...$info]; + } + + return [ + 'cycleView' => $cycleView, 'cycleQuery' => $cycleQuery, 'cycleSlice' => InMemoryListing::page($cycleRows, $cycleQuery, ['id'], 'id'), + 'unresolvedView' => $unresolvedView, 'unresolvedQuery' => $unresolvedQuery, 'unresolvedSlice' => InMemoryListing::page($unresolvedRows, $unresolvedQuery, ['id'], 'id'), + 'pathView' => $pathView, 'pathQuery' => $pathQuery, 'pathSlice' => InMemoryListing::page($pathRows, $pathQuery, ['id'], 'row'), + 'moduleGraph' => $moduleGraph, 'modulePicked' => $modulePicked, 'moduleFocus' => $moduleFocus, + 'couplingView' => $couplingView, 'couplingQuery' => $couplingQuery, 'couplingSlice' => InMemoryListing::page($couplingRows, $couplingQuery, ['from', 'to'], 'row'), + 'moduleView' => $moduleView, 'moduleQuery' => $moduleQuery, 'moduleSlice' => InMemoryListing::page($moduleRows, $moduleQuery, ['id'], 'id'), + 'graph' => $graph, 'explorer' => $explorer, 'picked' => $picked, 'focus' => $focus, 'nodes' => $nodes, 'modules' => $modules, + 'conditions' => $conditions, 'cycles' => $cycles, 'relations' => $relations, 'relationSource' => $source, + 'starters' => array_slice($starters, 0, $this->settings->graph->starters), 'roots' => array_slice($roots, 0, $this->settings->graph->starters), + 'view' => $view, 'query' => $query, 'slice' => InMemoryListing::page($rows, $query, ['id', 'detail', 'stereotype'], 'id'), + 'exclusive' => $explorer->get('module') === '' ? [] : BeanModules::exclusive($graph, $explorer->get('module')), + ]; + } + /** * The two listings the Conditions page shows side by side. * diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index eb49649c..7aa2f4cb 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -51,7 +51,7 @@ public static function all(): array new self('beans', 'Beans', 'beans', self::GROUP_WIRING, 'Every bean the container registered, with the stereotype that declared it.'), - new self('graph', 'Bean graph', 'beans', self::GROUP_WIRING, + new self('graph', 'Bean explorer', 'beans', self::GROUP_WIRING, 'How your beans depend on one another, resolved through the interfaces they are wired by.'), new self('conditions', 'Conditions', 'conditions', self::GROUP_WIRING, 'Which auto-configurations applied, and which backed off because you supplied your own.'), diff --git a/packages/admin/tests/AdminSettingsTest.php b/packages/admin/tests/AdminSettingsTest.php index 93206319..c44c2898 100644 --- a/packages/admin/tests/AdminSettingsTest.php +++ b/packages/admin/tests/AdminSettingsTest.php @@ -81,3 +81,16 @@ function adminConfig(array $values): Config expect($settings->allows(''))->toBeFalse() ->and($settings->allows('beans'))->toBeTrue(); }); + +it('bounds the focus explorer independently of the deprecated global graph ceiling', function () { + $settings = AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['graph' => ['max-nodes' => 0, 'focus' => ['depth' => 999, 'max-rows' => -1, 'max-nodes' => 999, 'max-paths' => -1, 'page-size' => 999], 'starters' => 999, 'modules' => ['max-nodes' => -1]], 'beans' => ['page-size' => 999]]]])); + expect($settings->graphMaxNodes)->toBe(0) + ->and($settings->graph->depth)->toBe(4) + ->and($settings->graph->maxRows)->toBe(4) + ->and($settings->graph->maxNodes)->toBe(300) + ->and($settings->graph->maxPaths)->toBe(0) + ->and($settings->graph->pageSize)->toBe(500) + ->and($settings->graph->starters)->toBe(50) + ->and($settings->graph->moduleMaxNodes)->toBe(0) + ->and($settings->graph->beansPageSize)->toBe(500); +}); diff --git a/packages/admin/tests/AdminTableConditionsPagerTest.php b/packages/admin/tests/AdminTableConditionsPagerTest.php index 62ea30df..b4cb8d62 100644 --- a/packages/admin/tests/AdminTableConditionsPagerTest.php +++ b/packages/admin/tests/AdminTableConditionsPagerTest.php @@ -38,13 +38,13 @@ // Applied: every page link carries the Backed-off panel's size AND its page, and changes only `pos_page`. expect($body)->toMatch('~Previous~') - ->toContain('2') - ->toContain('3'); + ->toContain('2') + ->toContain('3'); // Backed off: the mirror, on the fixture's own five rows — three pages, the third of them current, and // a first-page jump that drops its own `neg_page` while keeping the neighbour's `pos_page` whole. - expect($body)->toContain('3') - ->toContain('1') + expect($body)->toContain('3') + ->toContain('1') ->toMatch('~Previous~') ->toMatch('~Next~'); }); @@ -70,6 +70,6 @@ ->toContain('RedisCacheAutoConfiguration') ->not->toContain('WidgetAutoConfiguration'); - expect($body)->toContain('3') + expect($body)->toContain('3') ->toMatch('~Previous~'); }); diff --git a/packages/admin/tests/AdminTableConfigPropsPagerTest.php b/packages/admin/tests/AdminTableConfigPropsPagerTest.php index d83c25df..08f4b527 100644 --- a/packages/admin/tests/AdminTableConfigPropsPagerTest.php +++ b/packages/admin/tests/AdminTableConfigPropsPagerTest.php @@ -32,13 +32,13 @@ // Bound values: every page link carries the Not-bound panel's size AND its page, and changes only // `props_page` — including the first-page jump, which drops its own page and keeps the neighbour's. - expect($body)->toContain('1') - ->toContain('2') - ->toContain('3'); + expect($body)->toContain('1') + ->toContain('2') + ->toContain('3'); // Not bound: the mirror, on the three profile-gated DTOs this fixture seeds. - expect($body)->toContain('2') - ->toContain('1') + expect($body)->toContain('2') + ->toContain('1') ->toMatch('~Next~'); }); @@ -58,7 +58,7 @@ expect($body)->toContain('ShippingProperties') ->not->toContain('ReportingProperties'); - expect($body)->toContain('3') + expect($body)->toContain('3') ->toMatch('~Previous~'); }); diff --git a/packages/admin/tests/AdminTableListingTest.php b/packages/admin/tests/AdminTableListingTest.php index 7cb74546..1b7dd8ea 100644 --- a/packages/admin/tests/AdminTableListingTest.php +++ b/packages/admin/tests/AdminTableListingTest.php @@ -341,8 +341,8 @@ /** @var AdminTableCapstoneTestCase $this */ $body = (string) $this->get('/firefly/configprops')->assertStatus(200)->getContent(); - expect($body)->toContain('Class↑') - ->toContain('Class'); + expect($body)->toContain('Class') + ->toContain('Class'); }); it('pages the cache stores and the log channels', function () { @@ -369,9 +369,9 @@ it('claims no ordering on a configuration listing the reader has not ordered', function () { /** @var AdminTableCapstoneTestCase $this */ $opening = [ - '/firefly/env' => 'Key', - '/firefly/caches' => 'Store', - '/firefly/loggers' => 'Channel', + '/firefly/env' => 'Key', + '/firefly/caches' => 'Store', + '/firefly/loggers' => 'Channel', ]; foreach ($opening as $url => $header) { @@ -380,7 +380,7 @@ expect($body)->toContain($header) // Not "no arrow on that column" but no arrow anywhere: the indicator is the page's one claim // about its own ordering, and an unordered listing makes none. - ->not->toContain('↑'); + ->not->toContain('