From c1219576cfe77aa3e3c15f655be146ec471cffbd Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:51:22 +0200
Subject: [PATCH] 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.
---
AGENTS.md | 23 ++-
.../Dev/LivewireRequestFailurePreview.php | 31 ++++
resources/js/app.js | 5 +
resources/js/livewire-request-failure.js | 68 ++++++++
resources/js/livewire-request-failure.test.js | 146 ++++++++++++++++++
resources/views/layouts/simple.blade.php | 3 +
...livewire-request-failure-preview.blade.php | 23 +++
routes/web.php | 4 +
.../LivewireRequestFailurePreviewTest.php | 47 ++++++
.../LivewireRequestFailurePreviewTest.php | 48 ++++++
10 files changed, 397 insertions(+), 1 deletion(-)
create mode 100644 app/Livewire/Dev/LivewireRequestFailurePreview.php
create mode 100644 resources/js/livewire-request-failure.js
create mode 100644 resources/js/livewire-request-failure.test.js
create mode 100644 resources/views/livewire/dev/livewire-request-failure-preview.blade.php
create mode 100644 tests/Feature/LivewireRequestFailurePreviewTest.php
create mode 100644 tests/v4/Browser/LivewireRequestFailurePreviewTest.php
diff --git a/AGENTS.md b/AGENTS.md
index 5563a18ec..4d78dfd93 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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/)
diff --git a/app/Livewire/Dev/LivewireRequestFailurePreview.php b/app/Livewire/Dev/LivewireRequestFailurePreview.php
new file mode 100644
index 000000000..5cdda5d78
--- /dev/null
+++ b/app/Livewire/Dev/LivewireRequestFailurePreview.php
@@ -0,0 +1,31 @@
+
+ */
+ 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(
+ '
Gateway time-out
cloudflare proxy error '.$status.'
',
+ $status,
+ ['Content-Type' => 'text/html']
+ ));
+ }
+
+ public function render(): mixed
+ {
+ return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple');
+ }
+}
diff --git a/resources/js/app.js b/resources/js/app.js
index bb41b7f04..11681efa0 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -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,
diff --git a/resources/js/livewire-request-failure.js b/resources/js/livewire-request-failure.js
new file mode 100644
index 000000000..e1bd82583
--- /dev/null
+++ b/resources/js/livewire-request-failure.js
@@ -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 }));
+ });
+}
diff --git a/resources/js/livewire-request-failure.test.js b/resources/js/livewire-request-failure.test.js
new file mode 100644
index 000000000..64a5cebbc
--- /dev/null
+++ b/resources/js/livewire-request-failure.test.js
@@ -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: 'proxy error', 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: 'proxy error',
+ }]);
+});
+
+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);
+});
diff --git a/resources/views/layouts/simple.blade.php b/resources/views/layouts/simple.blade.php
index 27248f4ec..e927e300c 100644
--- a/resources/views/layouts/simple.blade.php
+++ b/resources/views/layouts/simple.blade.php
@@ -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
{{ $slot }}
diff --git a/resources/views/livewire/dev/livewire-request-failure-preview.blade.php b/resources/views/livewire/dev/livewire-request-failure-preview.blade.php
new file mode 100644
index 000000000..f829b6401
--- /dev/null
+++ b/resources/views/livewire/dev/livewire-request-failure-preview.blade.php
@@ -0,0 +1,23 @@
+
+
+
Development tool
+
Livewire request failure preview
+
+ 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.
+
+
+
+
+ @foreach ($statuses as $status)
+
+ {{ in_array($status, [504, 522, 524], true) ? 'Gateway timeout' : 'Proxy unavailable' }}
+ {{ $status }}
+
+ @endforeach
+
+
+
+ This route is registered only when APP_ENV is local or testing.
+
+
diff --git a/routes/web.php b/routes/web.php
index d9b43a790..dddb0353b 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -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');
diff --git a/tests/Feature/LivewireRequestFailurePreviewTest.php b/tests/Feature/LivewireRequestFailurePreviewTest.php
new file mode 100644
index 000000000..8877d28ce
--- /dev/null
+++ b/tests/Feature/LivewireRequestFailurePreviewTest.php
@@ -0,0 +1,47 @@
+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('Gateway time-out
cloudflare proxy error 504
');
+});
+
+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);
+});
diff --git a/tests/v4/Browser/LivewireRequestFailurePreviewTest.php b/tests/v4/Browser/LivewireRequestFailurePreviewTest.php
new file mode 100644
index 000000000..f89893bc9
--- /dev/null
+++ b/tests/v4/Browser/LivewireRequestFailurePreviewTest.php
@@ -0,0 +1,48 @@
+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');
+});