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:
parent
379abb2526
commit
c1219576cf
10 changed files with 397 additions and 1 deletions
23
AGENTS.md
23
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/)
|
||||
|
|
|
|||
31
app/Livewire/Dev/LivewireRequestFailurePreview.php
Normal file
31
app/Livewire/Dev/LivewireRequestFailurePreview.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
68
resources/js/livewire-request-failure.js
Normal file
68
resources/js/livewire-request-failure.js
Normal 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 }));
|
||||
});
|
||||
}
|
||||
146
resources/js/livewire-request-failure.test.js
Normal file
146
resources/js/livewire-request-failure.test.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
47
tests/Feature/LivewireRequestFailurePreviewTest.php
Normal file
47
tests/Feature/LivewireRequestFailurePreviewTest.php
Normal 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);
|
||||
});
|
||||
48
tests/v4/Browser/LivewireRequestFailurePreviewTest.php
Normal file
48
tests/v4/Browser/LivewireRequestFailurePreviewTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue