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'); +});