feat(livewire): handle infrastructure request failures with toast

Add a local preview route and test coverage for proxy-style failures, suppressing raw Livewire error responses and showing a user-friendly toast after gestures.
This commit is contained in:
Andras Bacsai 2026-08-23 13:51:22 +02:00
parent 379abb2526
commit c1219576cf
10 changed files with 397 additions and 1 deletions

View file

@ -99,9 +99,30 @@ ### Writing Browser Tests
```
- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions.
- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`).
- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API.
### How Browser Tests Actually Run (no Docker, no display needed)
`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin:
1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`).
2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself.
Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres.
`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements).
### Browser Test Gotchas
- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`.
- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache.
- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`.
- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up.
- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds.
- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions.
- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible.
- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner.
## Architecture
### Backend Structure (app/)

View file

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Dev;
use Illuminate\Http\Exceptions\HttpResponseException;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
class LivewireRequestFailurePreview extends Component
{
/**
* @var list<int>
*/
public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530];
public function fail(int $status): never
{
abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND);
throw new HttpResponseException(response(
'<!doctype html><html><body><h1>Gateway time-out</h1><p>cloudflare proxy error '.$status.'</p></body></html>',
$status,
['Content-Type' => 'text/html']
));
}
public function render(): mixed
{
return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple');
}
}

View file

@ -1,4 +1,9 @@
import { initializeTerminalComponent } from './terminal.js';
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
document.addEventListener('livewire:init', () => {
registerLivewireRequestFailureHandler(window.Livewire);
});
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper,

View file

@ -0,0 +1,68 @@
export const INFRASTRUCTURE_FAILURE_STATUSES = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]);
const USER_GESTURE_EVENTS = ['click', 'submit', 'keydown', 'input', 'change'];
// A request sent within this window of a trusted user gesture is treated as
// user-initiated. It must cover Alpine $nextTick deferrals and wire:model
// debounces, while staying short enough to exclude most wire:poll requests.
export const GESTURE_WINDOW_MS = 2_000;
const WARN_COOLDOWN_MS = 10_000;
const WARN_CONTENT_MAX_LENGTH = 2_000;
export function createLivewireRequestFailureHandler({ now = Date.now } = {}) {
let lastWarnAt = Number.NEGATIVE_INFINITY;
let lastToastGestureAt = Number.NEGATIVE_INFINITY;
return ({ status, content, preventDefault, gestureAt = Number.NEGATIVE_INFINITY }) => {
if (!INFRASTRUCTURE_FAILURE_STATUSES.has(status)) {
return;
}
preventDefault();
const currentTime = now();
if (currentTime - lastWarnAt >= WARN_COOLDOWN_MS) {
lastWarnAt = currentTime;
console.warn('Livewire request failed', {
status,
content: typeof content === 'string' ? content.slice(0, WARN_CONTENT_MAX_LENGTH) : content,
});
}
// One toast per user gesture: a single click that fails several
// component requests toasts once, while a retry (a new gesture)
// always toasts again. Background requests carry no gesture.
if (gestureAt > lastToastGestureAt) {
lastToastGestureAt = gestureAt;
window.toast?.('Action could not be completed', {
type: 'danger',
description: 'Coolify did not receive a response. Please try again.',
});
}
};
}
export function registerLivewireRequestFailureHandler(Livewire, documentObject = document, { now = Date.now } = {}) {
let lastGestureAt = Number.NEGATIVE_INFINITY;
const markUserGesture = (event) => {
if (event.isTrusted) {
lastGestureAt = now();
}
};
USER_GESTURE_EVENTS.forEach((eventName) => {
documentObject.addEventListener(eventName, markUserGesture, true);
});
const handleFailure = createLivewireRequestFailureHandler({ now });
Livewire.hook('request', ({ fail }) => {
// Classify at send time: infrastructure failures (522/524) can arrive
// long after the gesture, so the failure timestamp is meaningless.
const gestureAt = now() - lastGestureAt <= GESTURE_WINDOW_MS ? lastGestureAt : Number.NEGATIVE_INFINITY;
fail((failure) => handleFailure({ ...failure, gestureAt }));
});
}

View file

@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
GESTURE_WINDOW_MS,
INFRASTRUCTURE_FAILURE_STATUSES,
createLivewireRequestFailureHandler,
registerLivewireRequestFailureHandler,
} from './livewire-request-failure.js';
function createHarness({ start = 100_000 } = {}) {
let currentTime = start;
let requestHook = null;
let toasts = 0;
let warnings = [];
const listeners = {};
global.window = { toast: () => toasts++ };
global.console = { warn: (...args) => warnings.push(args) };
registerLivewireRequestFailureHandler({
hook(name, callback) {
assert.equal(name, 'request');
requestHook = callback;
},
}, {
addEventListener(name, callback) {
listeners[name] = callback;
},
}, { now: () => currentTime });
return {
listeners,
advance: (ms) => currentTime += ms,
gesture: (event = { isTrusted: true }) => listeners.click(event),
fail(status) {
let prevented = false;
let failureCallback = null;
requestHook({ fail: (callback) => failureCallback = callback });
failureCallback({ status, content: '<html>proxy error</html>', preventDefault: () => prevented = true });
return prevented;
},
toasts: () => toasts,
warnings: () => warnings,
};
}
test('a failure after a trusted gesture suppresses the response and shows a toast', () => {
const harness = createHarness();
harness.gesture();
const prevented = harness.fail(504);
assert.equal(prevented, true);
assert.equal(harness.toasts(), 1);
});
test('background failures are suppressed and logged without a toast', () => {
const harness = createHarness();
const prevented = harness.fail(524);
assert.equal(prevented, true);
assert.equal(harness.toasts(), 0);
assert.equal(harness.warnings().length, 1);
assert.deepEqual(harness.warnings()[0], ['Livewire request failed', {
status: 524,
content: '<html>proxy error</html>',
}]);
});
test('one gesture toasts once, but a retry gesture toasts again', () => {
const harness = createHarness();
harness.gesture();
harness.fail(504);
harness.fail(504);
assert.equal(harness.toasts(), 1);
harness.advance(8_000);
harness.gesture();
harness.fail(504);
assert.equal(harness.toasts(), 2);
});
test('requests sent outside the gesture window count as background', () => {
const harness = createHarness();
harness.gesture();
harness.advance(GESTURE_WINDOW_MS + 1);
harness.fail(504);
assert.equal(harness.toasts(), 0);
});
test('untrusted synthetic events do not count as gestures', () => {
const harness = createHarness();
harness.gesture({ isTrusted: false });
harness.fail(504);
assert.equal(harness.toasts(), 0);
});
test('a missing window.toast does not throw', () => {
const harness = createHarness();
delete global.window.toast;
harness.gesture();
assert.doesNotThrow(() => harness.fail(504));
});
test('console warnings are throttled and truncated', () => {
const harness = createHarness();
harness.fail(502);
harness.fail(504);
assert.equal(harness.warnings().length, 1);
harness.advance(10_000);
harness.fail(504);
assert.equal(harness.warnings().length, 2);
const handler = createLivewireRequestFailureHandler({ now: () => 0 });
let logged = null;
global.console = { warn: (message, details) => logged = details };
handler({ status: 502, content: 'x'.repeat(5_000), preventDefault() {} });
assert.equal(logged.content.length, 2_000);
});
test('all supported infrastructure status codes are handled', () => {
const harness = createHarness();
for (const status of INFRASTRUCTURE_FAILURE_STATUSES) {
assert.equal(harness.fail(status), true, `expected ${status} to be handled`);
}
});
test('other failures keep Livewire default handling', () => {
const harness = createHarness();
harness.gesture();
for (const status of [401, 419, 422, 429, 500]) {
assert.equal(harness.fail(status), false, `expected ${status} to be untouched`);
}
assert.equal(harness.toasts(), 0);
});

View file

@ -1,5 +1,8 @@
@extends('layouts.base')
@section('body')
{{-- Manual @livewireScripts disables Livewire's asset auto-injection, so the
styles (e.g. [wire\:loading] { display: none }) must be rendered manually too. --}}
@livewireStyles
@livewireScripts
<main class="h-full bg-gray-50 dark:bg-base">
{{ $slot }}

View file

@ -0,0 +1,23 @@
<div class="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-12">
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold uppercase tracking-wider text-coollabs">Development tool</p>
<h1 class="text-2xl font-semibold text-neutral-950 dark:text-white">Livewire request failure preview</h1>
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Each button returns proxy-style HTML from a failed Livewire request. The page should remain visible and
Coolify should show a toast instead of Livewire's raw response modal.
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($statuses as $status)
<x-forms.button wire:click="fail({{ $status }})" class="justify-between">
<span>{{ in_array($status, [504, 522, 524], true) ? 'Gateway timeout' : 'Proxy unavailable' }}</span>
<span class="font-mono text-xs text-neutral-500 dark:text-fg-faint">{{ $status }}</span>
</x-forms.button>
@endforeach
</div>
<p class="text-xs text-neutral-500 dark:text-fg-faint">
This route is registered only when <code class="font-mono">APP_ENV</code> is local or testing.
</p>
</div>

View file

@ -11,6 +11,7 @@
use App\Livewire\Destination\Index as DestinationIndex;
use App\Livewire\Destination\Resources as DestinationResources;
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\Dev\LivewireRequestFailurePreview;
use App\Livewire\ForcePasswordReset;
use App\Livewire\Notifications\Discord as NotificationDiscord;
use App\Livewire\Notifications\Email as NotificationEmail;
@ -120,6 +121,9 @@
// Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production).
if (app()->environment(['local', 'testing'])) {
Route::get('/__livewire-request-failure', LivewireRequestFailurePreview::class)
->name('dev.livewire-request-failure-preview');
Route::get('/__exception', function () {
throw new RuntimeException('Testing Laravel exception page');
})->name('dev.exception-preview');

View file

@ -0,0 +1,47 @@
<?php
use App\Livewire\Dev\LivewireRequestFailurePreview;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0]);
});
it('registers the Livewire request failure preview in testing', function () {
expect(Route::has('dev.livewire-request-failure-preview'))->toBeTrue();
$this->get('/__livewire-request-failure')
->assertSuccessful()
->assertSee('Livewire request failure preview')
->assertSee('Gateway timeout')
->assertSee('504');
});
it('returns proxy-style html for supported statuses', function () {
Livewire::test(LivewireRequestFailurePreview::class)
->call('fail', 504)
->assertStatus(504)
->assertContent('<!doctype html><html><body><h1>Gateway time-out</h1><p>cloudflare proxy error 504</p></body></html>');
});
it('rejects statuses outside the supported list', function () {
Livewire::test(LivewireRequestFailurePreview::class)
->call('fail', 500)
->assertStatus(404);
});
it('keeps the preview statuses in sync with the JS handler', function () {
$source = file_get_contents(resource_path('js/livewire-request-failure.js'));
expect(preg_match('/INFRASTRUCTURE_FAILURE_STATUSES = new Set\(\[([\d,\s]+)\]\)/', $source, $matches))->toBe(1);
$jsStatuses = array_map('intval', array_map('trim', explode(',', $matches[1])));
expect((new LivewireRequestFailurePreview)->statuses)->toBe($jsStatuses);
});

View file

@ -0,0 +1,48 @@
<?php
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(RefreshDatabase::class);
beforeEach(function () {
// Host-side browser runs have no phpredis; keep maintenance checks off Redis.
config()->set('app.maintenance.store', 'array');
seedBrowserInstanceSettings();
createBrowserRootUser();
});
it('suppresses proxy error responses and shows a toast', function () {
$page = visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Welcome to Coolify');
// Boarding redirects every other path; finish it so the preview page loads.
// User::currentTeam() caches the team, so flush after the update.
Team::query()->update(['show_boarding' => false]);
Cache::flush();
$page->navigate('/__livewire-request-failure')
->assertSee('Livewire request failure preview')
->click('502')
->assertSee('Action could not be completed')
->assertSee('Coolify did not receive a response. Please try again.')
->assertSee('Livewire request failure preview')
->assertDontSee('cloudflare proxy error')
->screenshot(filename: 'livewire-request-failure-toast');
});
it('shows the preview page without a toast before any failure', function () {
$page = visit('/__livewire-request-failure');
$page->assertSee('Livewire request failure preview')
->assertDontSee('Action could not be completed')
->screenshot(filename: 'livewire-request-failure-initial');
// layouts.simple must render Livewire's styles, or wire:loading spinners leak.
$spinnerDisplay = $page->script('getComputedStyle(document.querySelector("[wire\\\\:loading]")).display');
expect($spinnerDisplay)->toBe('none');
});