Livewire → Inertia v3 + React: the console, ported whole - #5
Merged
Conversation
The foundation only — no page is served through it yet, and every Volt page still
renders exactly as it did. Both stacks build from one Vite config until the last
page is ported, which is what keeps the suite green through the middle of the move
instead of only at the ends.
What is here:
- inertiajs/inertia-laravel ^3.3 and laravel/wayfinder ^0.1. Wayfinder generates
TypeScript route helpers and form actions from routes/*.php on every dev start
and every build, so nothing in resources/js spells a URL and a renamed route is
a compile error rather than a 404 somebody finds later. The generated trees are
gitignored: they are a second spelling of the route table, not source.
- resources/views/app.blade.php — the one root document. It carries the two things
that cannot wait for React: the theme attribute (a cookie, because the server
cannot read localStorage) and the brand's token override as a <style> block, so
a branded sign-in never paints Cbox blue first. Both were already true; what
changed is that they are resolved once, in App\Platform\Appearance\BrandContext,
instead of by whichever layout a page happened to declare.
- Shared props as typed objects under app/Http/Props/, not associative arrays.
Inertia resolves Arrayable recursively, so a readonly class with a constructor
can be handed straight to a render — and a prop cannot reach the browser
half-built with a key the page assumed.
- A local-only Vite origin in the CSP. @Vite in development points the page at a
second origin, and script-src 'self' refused all of it — so running the dev
server meant switching the policy off, which is how a machine ends up testing a
policy it does not run. Guarded on APP_ENV=local AND the presence of the hot
file Vite writes, so a built image cannot reach that branch.
Two renames the generator forced, both of which were latent ambiguities:
- PasskeySignInController::options() is now ::challenge(). The URI is unchanged
(it is published in the Frontend API and the SDKs), but the route is registered
for ['post', 'options'] and an action sharing a name with an HTTP verb on its
own route cannot be generated into a typed client unambiguously.
- The route named compliance.exports is now compliance.data-exports. `exports` is
not a usable identifier in an ES module — TypeScript treats assignments to it as
CommonJS exports, so the generated helper had no methods. The URI and the Volt
component name are unchanged.
Toolchain: TypeScript 5.9 (typescript-eslint has no TS 7 support yet, and a linter
that cannot run is worse than an older compiler), oxlint in place of ESLint (the
react and jsx-a11y plugins do not support ESLint 10, and ESLint 9 is end-of-life),
Vitest with an axe matcher so accessibility is asserted per primitive.
…eady had
Twenty-six primitives, built against `resources/css/app.css` rather than beside it:
the shapes stay where they are, and these decide which of them applies. Nothing
visual moves — a `<Pill tone="success">` emits the same `.cbx-pill--success` the
Volt pages do, so a white-label token override reaches both. Verified in the
browser under a customer palette and under the default one, light and dark.
WHAT A COMPONENT BUYS THAT A CONVENTION DID NOT. The Volt console wrote label,
hint and error markup by hand at several hundred call sites, and the wiring
between them — `for`/`id`, `aria-describedby`, `aria-invalid`, a live region —
was a matter of remembering. `<Field>` generates the ids and the relationships
are not optional: a field cannot be built without a label, and an error cannot be
shown without being announced. Same story for the overlays. The Alpine dialog had
no focus trap at all, because the Alpine Focus plugin is not loaded in this app —
Tab walked out of the dialog and into the page behind it. Radix owns that now,
and the test walks a full Tab cycle to prove it.
Three defects the tests found while being written:
- The dialog did not return focus on close. Radix restores to its own Trigger,
and this dialog deliberately has none (it is opened by a button, a menu item
that has since unmounted, a redirect) — so the default was to focus nothing,
dropping a keyboard user at the top of the document. `onOpenAutoFocus` is the
only moment the opener is still knowable; it is caught there.
- `<Button>` had no default `type`, which inside a form is `submit` — the way a
Cancel beside a Save submits the form.
- The type-to-confirm dialog held its typed name in the parent. Cleared in an
effect, a second opening could render ALREADY CONFIRMED for one frame, one
Enter away from destroying something nobody re-read. It mounts on open now,
so the field starts empty by construction.
Also: `CopyButton` waits for the clipboard promise. The version it replaces set
"Copied ✓" synchronously and unconditionally, so on an insecure origin or a denied
permission it claimed success over an empty clipboard — data loss on a secret that
is shown exactly once.
The checkbox and radio hit areas are grown to 24px with a pseudo-element while the
drawing stays 16px (WCAG 2.2 SC 2.5.8), and to 40px under a coarse pointer.
/dev/design-system draws all of it on one page, registered only when
`app()->environment('local')`. It exists because the suite cannot see whether a
control is DRAWN: a switch whose thumb is invisible against its track in dark mode
passes every test in this repository. DesignSystemRouteTest holds the guard —
removing it turns both cases red.
Toolchain notes: oxlint's react-perf object/array/JSX-prop rules are off. They
describe a real hazard only for a React.memo'd component, and nothing here is
memoized — left on, they fire on `options={[…]}` and `actions={<Button/>}`, which
is the design system's own API, and a linter people learn to ignore is worse than
no linter.
There were two — 385 lines of blade for the organization plane and 193 for the
environment one — each recomputing the same answers from the same sources in its
own idiom. The drift between them is written down all over both files: one carried
the impersonation banner and the other did not, so an operator who started an
impersonation from a page on the wrong layout got a banner-less console and no way
out except POSTing the exit endpoint by hand; one used the shared mobile navigation
and the other hand-rolled a drawer, so the same page behaved differently on a phone
depending on which plane served it.
None of that was a rendering problem. The chrome was described twice.
App\Platform\Console\ShellPayload is now the one description, and it is a typed
object rather than a nested array in a template — so the eyebrow, a breadcrumb and
a test can all read the same answer the rail does. Both planes go in and one shape
comes out. The organization plane's navigation still comes from the console-kit
registry, so a module that composer-requires in appears in the rail with no edit to
any of this.
The React side is arrangement only:
- Rail, with the three states the app-shell guideline specifies. The focus
handlers are not decoration: without them the rail expanded for a pointer and
never for a keyboard, so tabbing through the primary navigation moved between
unlabelled icons whose only text was a `title` attribute.
- Subnav, whose collapsed strip is a button. In the version it replaces it was a
div with a click handler, so a keyboard user who collapsed it lost tier-2
navigation for good — no tabindex, no role, no key handler, and the only way
back was a shortcut documented in the title of the control that had just
vanished.
- MobileNav, on Radix rather than the hand-rolled focus trap it replaces — which
existed because the Alpine Focus plugin is not loaded in this app, and had been
reimplemented, differently, in three places.
- A ⌘K palette that is navigation only. The Volt console's ⌘K box was REMOVED
rather than left in place because it searched nothing, and a dead affordance
teaches people the shortcut does not work. Everything here comes from the same
nav registry the rail draws from, so it offers exactly the pages this person can
reach on this plane with these features on — never one that would 404 them.
Searching entities needs an endpoint that scopes and authorizes the search;
that is a feature, not a widget, and shipping the widget first is how the last
one came to be deleted.
Three things the linter was right about, and one it was not:
- The sandbox banner's `role="status"` did nothing. A live region present at page
load is not announced — only updates to one are — and the text never changes.
What reaches a screen reader is the copy, read in document order. Role removed.
- The impersonation countdown resynced in an effect, rendering one frame of the
stale figure on every navigation. On a control whose whole job is to say how
long you have left, a visible jump backwards reads as a broken countdown. It
adopts the server's figure during render instead.
- The sub-nav's collapsed state was read from localStorage after mount, so a
176px column appeared and vanished on every navigation. Lazy initialiser.
- It objected to the rail's hover handlers and to `role="img"` on the inline
brand mark. Both are suppressed with the reason in place: hovering is not how
the rail is used, and the mark is filled from `var(--primary)` so it cannot be
an <img> — a data URI resolves none of this page's custom properties and would
paint one fixed colour on every theme and for every tenant.
The root view now paints the document background and carries the nav-pinned class
from its cookie, for the same reason it already carried the theme: a class applied
by JavaScript arrives after the first paint, and the console animated 52 → 210px on
every hard refresh for anybody who had pinned the rail.
…ooks
The seam first. ConsoleRoutes::page() registers a capability on both planes from
one declaration, and it now takes a CONTROLLER as readily as a Volt component —
so the six modules move by changing one argument, and a page can be ported without
touching the middleware stacks that guard it. The Volt branch is transitional and
goes with the last Volt page.
Then webhooks, end to end, as the shape everything else follows: a controller per
capability, a FormRequest per write, typed prop objects, and one React page per
route. Verified in a browser — created an endpoint, read the one-time secret,
rotated it, paused, resumed, deleted.
WHAT MOVING THE GUARDS OUT OF THE PAGE ACTUALLY CHANGED.
Under Volt every one of those six writes was an action POSTed to /livewire/update,
so no route middleware could see them and each page had to ask for itself — in
boot() rather than mount(), because only boot() runs per action. Now each write is
its own route:
- `console.admin` replaces sixty-odd hand-written boot() guards. It is also in
the persistent-middleware list: the exemption to justify is "a gate that need
not hold", and this one does.
- ReadOnlyWhileImpersonating replaces ImpersonationCallGuard's allowlist of
method names with the HTTP verb. That is strictly stronger — a new endpoint is
refused because it is a write, not because somebody remembered to think about
it — and it is registered globally, because a guard on a route group is a guard
the next route group will not carry. Livewire's endpoint stays exempt while the
old guard still covers it.
- The step-up now runs where it always should have: the create PAGE asks at the
door and the POST asks again, and the test posts DIRECTLY to prove the second
one is load-bearing. A gate that only lived on the form is satisfied by never
opening the form.
THREE TESTS THAT WERE ABOUT TO GO GREEN OVER NOTHING.
- EnvironmentAdminActionsTest drove the webhook mutations through Volt::test(),
which invokes a component directly and never routes — so it kept passing after
the routes stopped pointing at that component at all. Rewritten over HTTP, and
one of its assertions turns out to have been comparing an enum to a backing
string, which is never identical: it would have held with the pause deleted.
- ConsoleNavHonestyTest demanded a rail entry for every `environment.*` route.
That was free while every mutation was a Livewire action with no route name.
It asks about GET routes now — a page is something you navigate to; a POST is
something you do to one.
- ConsoleParityHealthCheck read routes/web.php with a regex that only knew
Volt::route, so it reported the ported capability as "registration not readable"
— and the install command failed its own health check, which is how this was
found. It asks the ROUTER for controller routes now, which is both simpler and
truer: it compares what will run rather than what a file says should.
The tab title moves to the server. ConsoleAreasTest holds that a nav entry, the
<title> and the page's heading all agree, and a client-rendered title would have
left the first paint of every console page saying nothing but the product name.
The controller states it once: the root view renders it, the layout re-renders it
through <Head> across client-side navigations, and <PageHeader> takes its h1 from
the same prop — so the three cannot disagree by construction, which is a stronger
invariant than the one the test used to check.
The CSP note in SecurityHeaders was wrong and is corrected: Inertia v3 puts page
props in a `<script type="application/json">`, not a data attribute. That is
compatible with `script-src 'self'` rather than an exception to it — a script
element with a non-JavaScript type is never prepared for execution, so the policy
is never consulted. Checked in a browser under the real policy.
Five pages, and the pattern for the rest of them: a controller per surface, a FormRequest per write, and the page's name stated once by the server. WHAT MOVED, AND WHAT THAT FIXED. The reset-from-a-link flow keeps the property the whole page is designed around — the confirmation is identical whether or not the address has an account here, and the throttle is keyed on the address so it cannot be used to probe. What it gains is a `<PasswordField>`: `autocomplete="new-password"` beside a hidden username field, so a password manager UPDATES the credential it already holds rather than saving a second one, and a `passwordrules` hint so Safari's generator produces a password this tenant's policy will actually accept rather than one refused after the person has already saved it. The forced change keeps its guard — only somebody the middleware is actually HOLDING may set a password here, on either of the two reasons it holds them, and the check sits immediately before the write against the same id the write uses. The exemption that stops the redirect looping is not a restriction, and without that guard this is an authenticated password-SET endpoint that asks for nothing. The account switcher is a POST now. It was a Livewire action, which is a POST by accident of the transport; it is one on purpose here, because a GET that changes who you are is a GET any image tag on any page can make. The social-link confirmation reads the pending link from the SESSION on submit rather than from what it displayed — the same reasoning the Volt version had, and now enforced by there being no submitted copy at all. ACCESSIBILITY MOVES TO A REAL BROWSER, page by page. The jsdom sweep audits the server's HTML, and a client-rendered page has nothing in its response but a mount point — so it would audit an empty document and report no violations, which is exactly the shape of green that guard's own docblock warns about. Its `strlen > 2000` check is what caught this rather than a silent pass. The replacement is Pest's browser plugin against the running app, and it is strictly better coverage: jsdom has no layout engine and no cascade, so the old bridge had to disable `color-contrast` — the one rule this design system's tokens are most carefully tuned for, and the one that has actually regressed here before. Each page moves as it ports; the jsdom file shrinks and goes with the last Volt page. Two `assertSee` tests migrate to `assertInertia` — the props, not the prose. The words are the page's to choose and it renders them in the browser; scanning the response body for them would fail the day somebody improves the copy, which is not a regression.
PHPStan was right and the code was working by luck: RouteCollectionInterface is not itself iterable, so `foreach (Route::getRoutes())` walked a value the analyser could only see as mixed. `getRoutes()` on it returns the array — which is what the nav-honesty test next door was already doing.
… needed
Login, sign-up, the second factor, the emailed step-up, invitation acceptance
and the step-up prompt. Driven in a real browser: signed out, signed in, walked
the identifier-first flow, watched the toast.
THE ONE THAT COULD ONLY BE FOUND BY LOOKING.
An Inertia form that redirects to a page Volt still serves breaks. The client
follows a redirect with an XHR and expects a page object back; ordinary HTML comes
back instead and it cannot render it, so it opens its error modal over the page.
That is what somebody signing in saw — the session was established, the redirect
was correct, and the screen filled with a black rectangle. No test could see it:
every assertion in the suite stops at the 302.
The protocol already had the answer (a 409 carrying `X-Inertia-Location`); what was
missing was knowing when. RedirectOutsideInertia decides it from the destination —
another origin, or a route this app serves with a closure, which on this codebase
means Volt — rather than leaving each controller to remember. The Volt half goes
with the last Volt page; the cross-origin half stays, because an XHR can never
follow a redirect to an identity provider.
A SECOND ONE THE TESTS DID CATCH. BrandContext was resolved fresh at each of its
three call sites, so the controller pinned an organization onto one instance while
the root view and the Inertia middleware read two empty ones — a branded sign-in
painting Cbox blue. It is `scoped` now: written once per request, read twice.
TWO CHANNELS, WITH A RULE.
Props are state and are written into the browser's history entry, which is what
makes Back instant. Inertia's flash is a one-shot and is not. Everything that is a
step in a flow — the identifier step passed, a link was sent, a CAPTCHA was asked
for, a mandate refused THIS attempt — moved to the flash, and so did the one-time
signing secret: a credential in a history entry is a credential at rest, readable
by pressing Back long after the page that showed it has gone.
Laravel's own session flash stays as the `flash` shared prop. It is the bridge
while the port runs — `->with('status', …)` works from a Volt page to a React one
and back — and it goes when Volt does.
WHAT THE PAGES GAINED.
- A RouteAnnouncer in every layout. A client-side navigation announces nothing
and moves no focus; `wire:navigate` did this for free and Inertia does not, so
a screen-reader user got a silently swapped page. Focus is deliberately NOT
moved: it would fight the filter-while-typing navigations this console is full of.
- `<PasswordField>` everywhere a password is set or proved — a reveal toggle with
a spoken state, `passwordrules` so Safari's generator produces something this
tenant's policy will accept, and a hidden username field so a manager updates
the credential it holds instead of saving a second one.
- `<Field labelAction>` for "Forgot password?" and "Use a different email". The
markup it replaces had a visible heading beside the link and a second,
screen-reader-only label for the input — two names for one field, one of them
invisible.
- WebAuthn as a typed module rather than a delegated click handler on `document`,
and a Turnstile component using `render=explicit`, which needs no global
callback name and therefore no shim to publish one.
Test migration continues: five mandate screens now assert the flash the door wrote
rather than the sentence the page renders from it, and the exact `RefusedFactor`
sentence rather than a fragment of it. The jsdom a11y sweep has no public auth page
left in it — they are all audited in a real browser now, where colour contrast is
actually computed.
…with it The last of the Volt console is ported: the platform plane, the account and device surfaces, the OAuth consent screen and the admin portal. `livewire/livewire` and `livewire/volt` are out of composer, `resources/views/livewire` and the whole Blade component tree are deleted, and `'unsafe-eval'` — which existed only because Alpine evaluates its expressions with `new Function` — is out of the CSP. THE GUARD THAT ONLY EXISTED BECAUSE OF THE SEAM Every console mutation used to be a component method POSTed to one `/livewire/update` endpoint, so route middleware never saw the individual action. That is why `ImpersonationCallGuard` hung off Livewire's `call` event and why `PersistentMiddleware` had a list at all: a guard not on that list ran on the page load and stopped enforcing on every action behind it. Both are gone with the endpoint. What replaces them is not nothing. `ReadOnlyWhileImpersonating` answers "is this a write?" with the HTTP method, globally in the `web` group — deny-by-default without an allowlist of method names. And `PersistentMiddlewareTest`, which had been ERRORING since `composer remove` (it reflected on a class that no longer exists), is replaced by `WriteRouteStackTest`: derived from the router, it holds that every write is guarded at least as tightly as the pages it sits beside, and that the read-only rule reaches every stateful route. Both mutation-checked. One documented exception, `sso.saml.acs`, is named with its reason rather than the route being exempted wholesale. THREE REAL DEFECTS, none of which the sqlite suite could see - `ConfirmDelete` lost `autocapitalize`/`autocorrect`/`spellcheck` in the port. iOS capitalises the first letter, so the typed name could never match the resource name and no destructive action could be confirmed on a phone at all. The disabled confirm button now describes why it is disabled, which was the other half of the same bug. - `InvitationController` granted roles with a raw `Roles::assign()` past `GrantAccessRole`, applying the same SoD rule through a second signature — the drift its own comment worried about. Routed through the gate. - `legacy_login_declarations.client_id` is four characters too narrow for a `cid_` id: PostgreSQL refuses the insert, MySQL truncates it to one that matches no client, and sqlite ignores widths. Repaired here; fixed upstream too. SWEEPS THAT HAD LOST THEIR POPULATION Four were passing over nothing — the SoD grant sweep, the brand-icon layouts, the copy sweep's coverage floor and the environment-plane port check all globbed directories that no longer exist. Each follows the code rather than being retired, and none had its floor lowered: the copy sweep now reaches the module React pages it always claimed to cover. `docs/security/livewire-action-sweep.md` is `console-action-sweep.md`, its recipe rewritten for controller routes and its old findings kept as the evidence they are. AND TWO TESTS THAT COULD NOT SEE WHAT THEY CLAIMED Found by opening the console on a phone by hand. Under `->on()->mobile()` this harness emulates touch, and two things stop working silently: a click never completes (no `touchend`, so no `click`), and `->script()` evaluates against a document React has not rendered into. So the horizontal-scroll check was measuring an empty page, and nothing had ever opened the mobile drawer — which at that width is the only navigation there is. Both now run at a plain narrow viewport, with a rendered-page guard, and both are mutation-checked. Gate, all green locally before this was pushed: composer qa (1894 tests, phpstan at max with no baseline, licences, SBOM, audit); the full suite on PostgreSQL 17 and MySQL 8.4 from an empty schema; npm ci/build/typecheck/lint/test; the browser suite including axe on both planes, in the dark and at a phone width; and `composer setup` from a bare database on both deployment shapes.
…olt classes
THE LOCKFILE HELD ONE NATIVE BINARY: darwin-arm64.
`npm ci` on the Linux runner installed no rollup binary at all and the build died on
"Cannot find module @rollup/rollup-linux-x64-gnu" — before a single test ran. The same
hole was in esbuild, lightningcss, oxlint and tailwind-oxide: every native optional
dependency had exactly one platform recorded, this machine's.
`main`'s lockfile carries all 25 rollup platforms, so this was introduced during the
port — an `npm install` that pruned the others and was never exercised anywhere but an
ARM Mac. `npm install --package-lock-only` does NOT restore them; the lockfile has to be
regenerated from an empty `node_modules`. Now 25 rollup / 26 esbuild / 19 oxlint / 12
oxide entries, and the 18 in-range version bumps that came with the fresh resolve are
verified: build, typecheck, lint, and Vitest's 85.
ELEVEN CLASSES THE STYLESHEET STILL DEFINED AND NOTHING WORE
`.cbx-sidebar`, `.cbx-toast`, `.cbx-flow*`, `.cbx-help-panel` and the rest — Alpine-era
primitives whose React replacements style themselves (the toast is `sonner`, the help
panel is a Radix popper). One said so in its own comment: "that class stays until the
last Volt page that uses it is ported, and goes with it." The page was ported and the
class was not, because nothing was watching.
Dead CSS is not just weight — it leaves a reader two ways to build the same control and
no way to tell which is current, which is how `.cbx-help-panel` and `.cbx-help-content`
came to coexist positioned by two incompatible mechanisms.
`StylesheetClassTest` now fails on any `cbx-` class nothing wears, with the modifier case
(`cbx-dialog--${size}`, built rather than written) handled by its stem. Mutation-checked
— and it caught itself first: the comment explaining `.cbx-help-panel`'s removal names
the class, so the sweep read its own epitaph as a live selector until it learned to strip
comments.
Suite re-run against the refreshed dependencies: 1802 passed.
…g at it (#6) * chore(test): Pest 5 and PHPUnit 13 pestphp/pest 4.7.5 → 5.1.3, pest-plugin-browser 4.3.1 → 5.0.1, phpunit 12.5.30 → 13.3.1, and the arch/mutate/profanity plugins with them. NO TEST CHANGED. 14 Unit, 1788 Feature and 93 Browser pass unmodified, with pint and phpstan at level max clean — which is worth stating rather than assuming, because a PHPUnit major usually is not free. `composer.json` already required `php ^8.4`, which is Pest 5's floor, and `ext-sockets` is present for the browser plugin's new transport. THE BROWSER PLUGIN IS THE REASON TO DO THIS NOW. v5 replaces the npm `playwright run-server` child process with an amphp websocket client and HTTP server. The 4.x arrangement was fragile in a way that cost most of a day: a killed run leaves `.temp/playwright-server.json` pointing at a dead port and never runs `markAsStopped()`, and `fromPersisted()` dials that pointer without checking anything is listening. That path is only reached under `Parallel::isWorker()`, so it was not what hung this suite — but it is a hang rather than a failure, waiting for whoever turns on parallel testing. The suite that hung for eight hours yesterday runs in 67 seconds here. * test(browser): drive the destructive dialogs and the whole sign-in door Six tests over the two things a request-level suite structurally cannot reach. DESTRUCTIVE ACTIONS. Every existing test of these posts straight to the route, which proves the server handles a well-formed request and says nothing about the dialog between the person and that request. The dialog is where the safety lives: a name typed exactly, a button dead until it matches, the consequence in words. So the dialog is opened and the confirm button is asserted DISABLED with the field empty, with a wrong case, and with a prefix — three near misses, because the mobile-keyboard bug that shipped in this component was exactly a near miss. Then the same dialog is driven to completion, because a confirmation nobody can satisfy is its own outage. Asserted as a STATE, not by clicking: a click on a disabled control times out waiting for actionability, which is a five-second way of learning nothing and reads as a broken test rather than a working guard. CREDENTIALS SHOWN ONCE. "Once" is a claim about the SECOND page load, so it cannot be tested anywhere but a browser: rotate, read the secret off the screen, ask for the same URL again, require it to be gone and the page to say where it went. Falsified by removing the reload — the assertion fails, so it is not passing vacuously. THE SIGN-IN DOOR, END TO END. Every other test of this starts from a session built in PHP, so the two-step form, the redirect that lands you where you were going, and the sign-out that must work from anywhere were exercised by nothing that renders them. Three journeys: in and out again, a refusal that is visible and keeps the email, and an interrupted visit that resumes at the page it was asked for. TWO TRAPS WORTH KNOWING, both cost me a wrong diagnosis first: - `assertPathIs` does NOT wait for a navigation — it reads the URL as it stands. After a submit it therefore describes the page being left. `assertSee` waits for the render, so text goes first and the path second. Written the other way round these fail on a working sign-in, which is exactly how I first wrote them. - `press()` matches VISIBLE TEXT, and this page's h1 also says "Sign in". The submit button is clicked by selector instead. Same family as the mobile drawer's trigger, whose accessible name and visible text disagree. Both new files mutation-checked: always-enabled confirm, and a redirect that forgets the intended URL. * test(browser): the states a form spends most of its life in A validation failure is not an edge case — it is what a form does whenever somebody is in a hurry, and it is the moment the page most needs to be clear. tests/Feature proves the SERVER refuses: a 422 with a message under the right key. Three separate ways that correct 422 can still be a broken page, none of them visible to a request test: - the message is drawn nowhere, so the form appears to do nothing when pressed - the message is drawn, but not on the field it is about - the form clears itself, punishing the mistake it just reported All three are asserted, plus the empty state — the one screen every new customer is guaranteed to see, and one a props test reads as `items: []` while saying nothing about whether the page then explains itself or draws a table head over nothing. FINDING A SERVER-ONLY RULE TOOK TWO TRIES, and the reason is worth writing down. The URL field is `type="url"`, so Chrome refuses a malformed value with its own bubble and never submits — a first draft used `not-a-url` and was testing the browser, not this application. `max:500` is a rule only the server knows, which is the shape of every server-only rule (uniqueness, ownership, entitlement) and therefore the shape worth proving the page can display at all. The fieldset error is asserted VISIBLE rather than present: it is a `role="alert"` paragraph that is `hidden` until there is something to say, and `hidden` is precisely the attribute that goes wrong — an element that never un-hides reads as "no error" to everybody and passes any test that only checks the DOM contains the text. Mutation-checked both ways: unwiring `error={form.errors.url}` from the Field, and pinning the fieldset's alert `hidden`. Each turns its own test red. * test(browser): hold the console's layout as a picture, locally Every other test in tests/Browser asserts something somebody named in advance: this text, that control, no axe violation. A layout regression is the opposite shape — nobody predicted it, and the page stays perfectly accessible and full of the right words while being visibly broken. A card grid that collapses to one column, a rail that doubles in width, a panel whose padding goes, a dark surface that loses its contrast: every one of those passes the entire suite today. Seven baselines, chosen as the console's distinct LAYOUTS rather than seven pages that happen to exist — the public door, the shell with its checklist, a long form, an empty state, each in the dark where it matters, and the shell at a phone width. Small on purpose: a baseline per page per theme per width is a cost paid on every intentional design change, and a suite that cries wolf gets regenerated without being read, which is worse than not having one. WHAT COULD NOT BE MADE STABLE IS ABSENT, and that is the trade rather than an oversight. The audit trail and the activity log are the densest tables in the console and the obvious things to pin; they render dates, so their pictures rot on a calendar. `/dashboard` went the same way — its audit-export card renders a pending count that differed between two consecutive runs. `travelTo()` is not the answer: it moves the clock in the test process, and the page is rendered by a separate server process that never hears about it. EXCLUDED FROM CI, deliberately and with the reason in the workflow. A screenshot baseline is a picture of ONE renderer: these are Chromium on macOS/arm64, and the CI runner is Linux/x64, where text rasterises differently and the pinned font is substituted. Every baseline would fail there for reasons nobody can act on. So this is a local gate today — `vendor/bin/pest --group=visual` before a design change, and READ THE DIFF rather than regenerating it. Making it a CI gate means generating the baselines inside the same container the job runs in, which is worth doing and is not this change. Mutation-checked twice, and the discrimination is the point: a `.cbx-panel` padding change fails the form alone, and a rail width change fails all four console pictures while leaving the two public ones and the phone — where the rail is not drawn — passing. Browser suite: 109 passed. * fix(test): a green summary and a red exit code, and a null array key on 8.5 TWO LINES FAILED EVERY CI LEG WHILE EVERY TEST PASSED. Tests: 1904 passed (10177 assertions) ##[error]Process completed with exit code 1 `EveryPageComponentExistsOnceTest` opened with `use RecursiveDirectoryIterator;` and `use RecursiveIteratorIterator;`. A Pest test file is in the global namespace, so importing a global class does nothing, and PHP 8.5 raises a warning saying so. `phpunit.xml` sets `failOnWarning="true"` — so a no-op import is a failing run. WHY IT TOOK SO LONG TO SEE. The warning is raised while the suite's files are being LOADED, before any test starts, so it belongs to no test: absent from the summary, absent from `--log-junit` (0 errors, 0 warnings, 4 tests), and not printed by `--display-all-issues`. `wasSuccessful()` returns TRUE; the exit code comes from a separate `failOn*` branch that nothing narrates. Finding it meant instrumenting Pest's kernel to enumerate the result object's warning events by hand. It also explains the shape of the reproduction, which had me looking in the wrong place: a single file path exits 0 and `--testsuite=Feature` exits 1, because a path loads one file and a suite loads all of them. `TestFilesRaiseNoWarningsTest` guards it, and names the cause in its failure message so the next person spends a second rather than an afternoon. It sweeps only the global-namespace test files — inside `app/` these imports are correct and necessary. Mutation-checked. AND A REAL BUG PHPSTAN ONLY SEES ON 8.5. `DashboardController::recentProps()` indexed `$labels[$targetId]` where the id may be null. PHP coerces a null key to `''`, so an audit entry with no target — a sign-in, a policy change — read whatever sits under the empty string rather than missing. Nothing puts a value there, which is the only reason it has been harmless. The null check comes first now. Worth recording that my "phpstan clean" was PHP 8.4 and production runs 8.5; the 8.5 leg had been red on the port branch too, so this was not new to Pest 5. Full suite: 1905 passed, exit 0. Pint and phpstan clean on 8.5. * chore(sbom): four Symfony components moved with the Pest 5 resolve `composer require --dev pestphp/pest:^5.0 -W` widened the resolve beyond the dev tree: symfony/console, finder and process went v8.1.x → v8.1.5 and polyfill-intl-normalizer v1.38.0 → v1.42.0. No dependency was added or removed — the SET is identical — but the committed SBOM no longer described the versions a build resolves, and the drift check says so on every leg. Same class as the laravel-id SBOM this week: a provenance record is only worth the last time somebody regenerated it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Livewire and Volt are gone. The console is Inertia v3 + React 19 + TypeScript, on the
same URLs, the same route names, the same authorization and the same two planes — 101
pages, ported rather than redesigned.
resources/css/app.cssis still the source oftruth for the design language, so runtime white-labelling works unchanged.
Why the seam mattered
Every console mutation used to be a component method POSTed to one
/livewire/updateendpoint, so route middleware never saw the individual action. That single fact is why
ImpersonationCallGuardhung off Livewire'scallevent, why authorization lived inboot()rather thanmount(), and why the persistent-middleware list existed at all —a guard missing from it ran on the page load and then stopped enforcing on everything the
page did. With
env.adminmissing, the whole environment control plane answeredunauthenticated action requests, and the snapshot checksum was keyed on
APP_KEY.Every mutation is now its own request, with its own verb and its own stack.
'unsafe-eval'left the CSP with the last Volt page — it was there because Alpineevaluates
x-expressions withnew Function.What replaced the guards, and how it is held
ReadOnlyWhileImpersonatinganswers "is this a write?" with the HTTP method, globally inthe
webgroup: deny-by-default, no allowlist of method names to keep current.PersistentMiddlewareTesthad been erroring sincecomposer remove— it reflected ona class that no longer exists.
WriteRouteStackTestreplaces it, derived from the routerrather than a hand-kept list:
One exception is named with its reason (
sso.saml.acs, which is the relying-party half offederation and must not carry
plane:issuer), and the ten writes with no page to compareagainst are listed rather than counted. Both mutation-checked.
Three defects found on the way
ConfirmDeletelostautocapitalize/autocorrect/spellcheck. iOS capitalisesthe first letter, so the typed name could never match and no destructive action could be
confirmed on a phone. The disabled confirm button now says why it is disabled.
InvitationControllergranted roles pastGrantAccessRole— the same SoD rulethrough a second signature, which is the drift its own comment worried about.
legacy_login_declarations.client_idis four characters too narrow for acid_id.PostgreSQL refuses the insert (
22001), MySQL truncates it to an id matching no client,sqlite ignores widths. Repaired by migration here and fixed upstream in
laravel-id(
25b3328) — including the fixture that let the engine matrix miss it, which declaredwith
'client-a'.Sweeps that had quietly lost their subject
Four were passing over deleted directories: the SoD grant sweep, the brand-icon layouts,
the copy sweep's coverage floor, and the environment-plane port check. Each follows the
code now instead of being retired, and none had its floor lowered — the copy sweep reaches
the module React pages it always claimed to cover.
Two tests that could not see what they claimed
Found by opening the console on a phone by hand. Under
->on()->mobile()the harnessemulates touch, and two things fail silently: a click never completes (no
touchend, sono
clickis synthesised), and->script()evaluates against a document React has notrendered into. So the horizontal-scroll check was measuring an empty page, and nothing
had ever opened the mobile drawer — which at that width is the only navigation there is.
Both run at a plain narrow viewport now, with a rendered-page guard.
AccessibilityTestkeeps the device preset, correctly:
assertScriptand axe do wait for the render, checkedagainst a deliberately overflowing page.
Verification
All green locally before this was pushed:
composer qanpm ci && build && typecheck && lint && testcomposer setupfrom a bare databasegrep -ri "livewire|wire:"composer showlists nothingDeliberately not in this PR
SSR. cbox-id is self-hostable and ships one runtime; a Node SSR process makes a
stranger's install harder. Per-page code splitting, instant visits and prefetch instead,
with branding and theme server-rendered in the root view so a branded login paints before
hydration.
ssr.tsxcan be added later without touching a page.