From 50853728bcb785244bdf1748647e63c66141e6d4 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 09:43:05 +0100 Subject: [PATCH 1/4] refactor(#120): remove unnecessary js, improve cache handling --- .../views/livewire/catalog/index.blade.php | 150 +++------ .../views/livewire/catalog/trmnl.blade.php | 303 ++++++++---------- tests/Feature/Livewire/Catalog/IndexTest.php | 83 +++++ tests/Feature/Volt/CatalogTrmnlTest.php | 62 ++++ 4 files changed, 324 insertions(+), 274 deletions(-) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 83a34fc..3a24b7e 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -1,20 +1,24 @@ filter(function ($plugin) use ($currentVersion) { // Check if Laravel compatibility is true - if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { + if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { return false; } @@ -81,8 +85,9 @@ class extends Component { }) ->sortBy('name') ->toArray(); - } catch (\Exception $e) { - Log::error('Failed to load catalog from URL: ' . $e->getMessage()); + } catch (Exception $e) { + Log::error('Failed to load catalog from URL: '.$e->getMessage()); + return []; } }); @@ -94,8 +99,9 @@ class extends Component { $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (!$plugin || !$plugin['zip_url']) { + if (! $plugin || ! $plugin['zip_url']) { $this->addError('installation', 'Plugin not found or no download URL available.'); + return; } @@ -113,8 +119,8 @@ class extends Component { $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); - } catch (\Exception $e) { - $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } catch (Exception $e) { + $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); } finally { $this->installingPlugin = ''; } @@ -124,32 +130,27 @@ class extends Component { { $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (!$plugin) { + if (! $plugin) { $this->addError('preview', 'Plugin not found.'); + return; } $this->previewingPlugin = $pluginId; $this->previewData = $plugin; - - // Store scroll position for restoration later - $this->dispatch('store-scroll-position'); } public function closePreview(): void { $this->previewingPlugin = ''; $this->previewData = []; - - // Restore scroll position when returning to catalog - $this->dispatch('restore-scroll-position'); } }; ?>
@if(empty($catalogPlugins))
- + No plugins available Catalog is empty
@@ -165,25 +166,25 @@ class extends Component { @if($plugin['logo_url']) {{ $plugin['name'] }} @else -
- +
+
@endif
-

{{ $plugin['name'] }}

+ {{ $plugin['name'] }} @if ($plugin['github']) -

by {{ $plugin['github'] }}

+ by {{ $plugin['github'] }} @endif
@if($plugin['license']) - {{ $plugin['license'] }} + {{ $plugin['license'] }} @endif @if($plugin['repo_url']) - + @endif @@ -191,7 +192,7 @@ class extends Component {
@if($plugin['description']) -

{{ $plugin['description'] }}

+ {{ $plugin['description'] }} @endif
@@ -201,14 +202,16 @@ class extends Component { Install - - - Preview - - + @if($plugin['screenshot_url']) + + + Preview + + + @endif @@ -236,34 +239,20 @@ class extends Component {
- @if($previewData['screenshot_url']) -
- Preview of {{ $previewData['name'] }} -
- @elseif($previewData['logo_url']) -
- {{ $previewData['name'] }} logo -

No preview image available

-
- @else -
- -

No preview available

-
- @endif +
+ Preview of {{ $previewData['name'] }} +
@if($previewData['description']) -
-

Description

-

{{ $previewData['description'] }}

+
+ Description + {{ $previewData['description'] }}
@endif -
+
- -@script - -@endscript diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 1b5dd50..dd97e0e 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -1,20 +1,24 @@ 'newest', ]); - if (!$response->successful()) { - throw new \RuntimeException('Failed to fetch TRMNL recipes'); + if (! $response->successful()) { + throw new RuntimeException('Failed to fetch TRMNL recipes'); } $json = $response->json(); $data = $json['data'] ?? []; + return $this->mapRecipes($data); }); - } catch (\Throwable $e) { - Log::error('TRMNL catalog load error: ' . $e->getMessage()); + } catch (Throwable $e) { + Log::error('TRMNL catalog load error: '.$e->getMessage()); $this->recipes = []; } } @@ -62,23 +67,24 @@ class extends Component { { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_' . md5($term); + $cacheKey = 'trmnl_recipes_search_'.md5($term); $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { $response = Http::get('https://usetrmnl.com/recipes.json', [ 'search' => $term, 'sort-by' => 'newest', ]); - if (!$response->successful()) { - throw new \RuntimeException('Failed to search TRMNL recipes'); + if (! $response->successful()) { + throw new RuntimeException('Failed to search TRMNL recipes'); } $json = $response->json(); $data = $json['data'] ?? []; + return $this->mapRecipes($data); }); - } catch (\Throwable $e) { - Log::error('TRMNL catalog search error: ' . $e->getMessage()); + } catch (Throwable $e) { + Log::error('TRMNL catalog search error: '.$e->getMessage()); $this->recipes = []; } finally { $this->isSearching = false; @@ -87,13 +93,14 @@ class extends Component { public function updatedSearch(): void { - $term = trim($this->search); + $term = mb_trim($this->search); if ($term === '') { $this->loadNewest(); + return; } - if (strlen($term) < 2) { + if (mb_strlen($term) < 2) { // Require at least 2 chars to avoid noisy calls return; } @@ -121,62 +128,78 @@ class extends Component { $this->dispatch('plugin-installed'); Flux::modal('import-from-trmnl-catalog')->close(); - } catch (\Exception $e) { - Log::error('Plugin installation failed: ' . $e->getMessage()); - $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } catch (Exception $e) { + Log::error('Plugin installation failed: '.$e->getMessage()); + $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); } } public function previewRecipe(string $recipeId): void { - $recipe = collect($this->recipes)->firstWhere('id', $recipeId); - - if (!$recipe) { - $this->addError('preview', 'Recipe not found.'); - return; - } - $this->previewingRecipe = $recipeId; - $this->previewData = $recipe; - - // Store scroll position for restoration later - $this->dispatch('store-scroll-position'); - } - - public function closePreview(): void - { - $this->previewingRecipe = ''; $this->previewData = []; - // Restore scroll position when returning to catalog - $this->dispatch('restore-scroll-position'); + try { + $response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json"); + + if ($response->successful()) { + $item = $response->json()['data'] ?? []; + $this->previewData = $this->mapRecipe($item); + } else { + // Fallback to searching for the specific recipe if single endpoint doesn't exist + $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ + 'search' => $recipeId, + ]); + + if ($response->successful()) { + $data = $response->json()['data'] ?? []; + $item = collect($data)->firstWhere('id', $recipeId); + if ($item) { + $this->previewData = $this->mapRecipe($item); + } + } + } + } catch (Throwable $e) { + Log::error('TRMNL catalog preview fetch error: '.$e->getMessage()); + } + + if (empty($this->previewData)) { + $this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? []; + } } /** - * @param array> $items + * @param array> $items * @return array> */ private function mapRecipes(array $items): array { return collect($items) - ->map(function (array $item) { - return [ - 'id' => $item['id'] ?? null, - 'name' => $item['name'] ?? 'Untitled', - 'icon_url' => $item['icon_url'] ?? null, - 'screenshot_url' => $item['screenshot_url'] ?? null, - 'author_bio' => is_array($item['author_bio'] ?? null) - ? strip_tags($item['author_bio']['description'] ?? null) - : null, - 'stats' => [ - 'installs' => data_get($item, 'stats.installs'), - 'forks' => data_get($item, 'stats.forks'), - ], - 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null, - ]; - }) + ->map(fn (array $item) => $this->mapRecipe($item)) ->toArray(); } + + /** + * @param array $item + * @return array + */ + private function mapRecipe(array $item): array + { + return [ + 'id' => $item['id'] ?? null, + 'name' => $item['name'] ?? 'Untitled', + 'icon_url' => $item['icon_url'] ?? null, + 'screenshot_url' => $item['screenshot_url'] ?? null, + 'author_bio' => is_array($item['author_bio'] ?? null) + ? strip_tags($item['author_bio']['description'] ?? null) + : null, + 'stats' => [ + 'installs' => data_get($item, 'stats.installs'), + 'forks' => data_get($item, 'stats.forks'), + ], + 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null, + ]; + } }; ?>
@@ -188,7 +211,7 @@ class extends Component { icon="magnifying-glass" />
- Newest + Newest
@error('installation') @@ -197,7 +220,7 @@ class extends Component { @if(empty($recipes))
- + No recipes found Try a different search term
@@ -211,8 +234,8 @@ class extends Component { @if($thumb) {{ $recipe['name'] }} @else -
- +
+
@endif @@ -221,12 +244,12 @@ class extends Component {
{{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} + Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@if($recipe['detail_url']) - + @endif @@ -246,7 +269,7 @@ class extends Component { @endif - @if($recipe['id']) + @if($recipe['id'] && ($recipe['screenshot_url'] ?? null)) - @if($previewingRecipe && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Recipe' }} +
+
+ + Fetching recipe details...
+
-
- @if($previewData['screenshot_url']) +
+ @if($previewingRecipe && !empty($previewData)) +
+ Preview {{ $previewData['name'] ?? 'Recipe' }} +
+ +
Preview of {{ $previewData['name'] }}
- @elseif($previewData['icon_url']) -
- {{ $previewData['name'] }} icon - No preview image available -
- @else -
- - No preview available -
- @endif - @if($previewData['author_bio']) -
-
- Description - {{ $previewData['author_bio'] }} + @if($previewData['author_bio']) +
+
+ Description + {{ $previewData['author_bio'] }} +
-
- @endif - - @if(data_get($previewData, 'stats.installs')) -
-
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - -
-
- @endif - -
- @if($previewData['detail_url']) - - View on TRMNL - @endif - - - Install Recipe - - + + @if(data_get($previewData, 'stats.installs')) +
+
+ Statistics + + Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} + +
+
+ @endif + +
+ @if($previewData['detail_url']) + + View on TRMNL + + @endif + + + Install Recipe + + +
-
- @endif + @endif +
- -@script - -@endscript diff --git a/tests/Feature/Livewire/Catalog/IndexTest.php b/tests/Feature/Livewire/Catalog/IndexTest.php index 22ab4b6..1b2efba 100644 --- a/tests/Feature/Livewire/Catalog/IndexTest.php +++ b/tests/Feature/Livewire/Catalog/IndexTest.php @@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void { $component->assertSee('testuser'); $component->assertSee('A test plugin'); $component->assertSee('MIT'); + $component->assertSee('Preview'); +}); + +it('hides preview button when screenshot_url is missing', function (): void { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL without screenshot_url + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin Without Screenshot', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'byos' => [ + 'byos_laravel' => [ + 'compatibility' => true, + ], + ], + 'logo_url' => 'https://example.com/logo.png', + 'screenshot_url' => null, + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.index') + ->assertSee('Test Plugin Without Screenshot') + ->assertDontSeeHtml('variant="subtle" icon="eye"'); }); it('shows error when plugin not found', function (): void { @@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void { $component->assertHasErrors(); }); + +it('can preview a plugin', function (): void { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin description', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'byos' => [ + 'byos_laravel' => [ + 'compatibility' => true, + ], + ], + 'logo_url' => 'https://example.com/logo.png', + 'screenshot_url' => 'https://example.com/screenshot.png', + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.index') + ->assertSee('Test Plugin') + ->call('previewPlugin', 'test-plugin') + ->assertSet('previewingPlugin', 'test-plugin') + ->assertSet('previewData.name', 'Test Plugin') + ->assertSee('Preview Test Plugin') + ->assertSee('A test plugin description'); +}); diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index ba1b722..4c338df 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -28,9 +28,33 @@ it('loads newest TRMNL recipes on mount', function (): void { Volt::test('catalog.trmnl') ->assertSee('Weather Chum') ->assertSee('Install') + ->assertDontSeeHtml('variant="subtle" icon="eye"') ->assertSee('Installs: 10'); }); +it('shows preview button when screenshot_url is provided', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/screenshot.png', + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->assertSee('Preview'); +}); + it('searches TRMNL recipes when search term is provided', function (): void { Http::fake([ // First call (mount -> newest) @@ -152,3 +176,41 @@ it('shows error when plugin installation fails', function (): void { ->call('installPlugin', '123') ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid }); + +it('previews a recipe with async fetch', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/old.png', + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + 'usetrmnl.com/recipes/123.json' => Http::response([ + 'data' => [ + 'id' => 123, + 'name' => 'Weather Chum Updated', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/new.png', + 'author_bio' => ['description' => 'New bio'], + 'stats' => ['installs' => 11, 'forks' => 3], + ], + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->call('previewRecipe', '123') + ->assertSet('previewingRecipe', '123') + ->assertSet('previewData.name', 'Weather Chum Updated') + ->assertSet('previewData.screenshot_url', 'https://example.com/new.png') + ->assertSee('Preview Weather Chum Updated') + ->assertSee('New bio'); +}); From 3250bb0402730e3195d4415b6904b7371199651c Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 10:28:41 +0100 Subject: [PATCH 2/4] fix: install loading spinner not shown after catalog search --- resources/views/livewire/catalog/index.blade.php | 2 +- resources/views/livewire/catalog/trmnl.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 3a24b7e..7257ab0 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -161,7 +161,7 @@ class extends Component @enderror @foreach($catalogPlugins as $plugin) -
+
@if($plugin['logo_url']) {{ $plugin['name'] }} diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index dd97e0e..8efd6b5 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -227,7 +227,7 @@ class extends Component @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) From 7f97114f6e521355bafce523dd867fc761a2b644 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 10:52:54 +0100 Subject: [PATCH 3/4] feat: add trmnl catalog paginator --- .../views/livewire/catalog/trmnl.blade.php | 77 +++++++++++++++---- tests/Feature/Volt/CatalogTrmnlTest.php | 70 +++++++++++++++++ 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8efd6b5..9ecad1a 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -13,6 +13,10 @@ class extends Component { public array $recipes = []; + public int $page = 1; + + public bool $hasMore = false; + public string $search = ''; public bool $isSearching = false; @@ -43,23 +47,36 @@ class extends Component private function loadNewest(): void { try { - $this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { + $cacheKey = 'trmnl_recipes_newest_page_'.$this->page; + $response = Cache::remember($cacheKey, 43200, function () { $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ 'sort-by' => 'newest', + 'page' => $this->page, ]); if (! $response->successful()) { throw new RuntimeException('Failed to fetch TRMNL recipes'); } - $json = $response->json(); - $data = $json['data'] ?? []; - - return $this->mapRecipes($data); + return $response->json(); }); + + $data = $response['data'] ?? []; + $mapped = $this->mapRecipes($data); + + if ($this->page === 1) { + $this->recipes = $mapped; + } else { + $this->recipes = array_merge($this->recipes, $mapped); + } + + $this->hasMore = ! empty($response['next_page_url']); } catch (Throwable $e) { Log::error('TRMNL catalog load error: '.$e->getMessage()); - $this->recipes = []; + if ($this->page === 1) { + $this->recipes = []; + } + $this->hasMore = false; } } @@ -67,32 +84,57 @@ class extends Component { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_'.md5($term); - $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { + $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page; + $response = Cache::remember($cacheKey, 300, function () use ($term) { $response = Http::get('https://usetrmnl.com/recipes.json', [ 'search' => $term, 'sort-by' => 'newest', + 'page' => $this->page, ]); if (! $response->successful()) { throw new RuntimeException('Failed to search TRMNL recipes'); } - $json = $response->json(); - $data = $json['data'] ?? []; - - return $this->mapRecipes($data); + return $response->json(); }); + + $data = $response['data'] ?? []; + $mapped = $this->mapRecipes($data); + + if ($this->page === 1) { + $this->recipes = $mapped; + } else { + $this->recipes = array_merge($this->recipes, $mapped); + } + + $this->hasMore = ! empty($response['next_page_url']); } catch (Throwable $e) { Log::error('TRMNL catalog search error: '.$e->getMessage()); - $this->recipes = []; + if ($this->page === 1) { + $this->recipes = []; + } + $this->hasMore = false; } finally { $this->isSearching = false; } } + public function loadMore(): void + { + $this->page++; + + $term = mb_trim($this->search); + if ($term === '' || mb_strlen($term) < 2) { + $this->loadNewest(); + } else { + $this->searchRecipes($term); + } + } + public function updatedSearch(): void { + $this->page = 1; $term = mb_trim($this->search); if ($term === '') { $this->loadNewest(); @@ -286,6 +328,15 @@ class extends Component
@endforeach
+ + @if($hasMore) +
+ + Load next page + Loading... + +
+ @endif @endif diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index 4c338df..a80c63a 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -214,3 +214,73 @@ it('previews a recipe with async fetch', function (): void { ->assertSee('Preview Weather Chum Updated') ->assertSee('New bio'); }); + +it('supports pagination and loading more recipes', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'name' => 'Recipe Page 1', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 1, 'forks' => 0], + ], + ], + 'next_page_url' => '/recipes.json?page=2', + ], 200), + 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([ + 'data' => [ + [ + 'id' => 2, + 'name' => 'Recipe Page 2', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 2, 'forks' => 0], + ], + ], + 'next_page_url' => null, + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Recipe Page 1') + ->assertDontSee('Recipe Page 2') + ->assertSee('Load next page') + ->call('loadMore') + ->assertSee('Recipe Page 1') + ->assertSee('Recipe Page 2') + ->assertDontSee('Load next page'); +}); + +it('resets pagination when search term changes', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence() + ->push([ + 'data' => [['id' => 1, 'name' => 'Initial 1']], + 'next_page_url' => '/recipes.json?page=2', + ]) + ->push([ + 'data' => [['id' => 3, 'name' => 'Initial 1 Again']], + 'next_page_url' => null, + ]), + 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([ + 'data' => [['id' => 2, 'name' => 'Weather Result']], + 'next_page_url' => null, + ]), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Initial 1') + ->call('loadMore') + ->set('search', 'weather') + ->assertSee('Weather Result') + ->assertDontSee('Initial 1') + ->assertSet('page', 1); +}); From 265972ac24930cfaebb05d1a85344d5e2437ade7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 14:09:31 +0100 Subject: [PATCH 4/4] fix(#130): server error on faulty recipes --- app/Services/ImageGenerationService.php | 25 ++- .../views/default-screens/error.blade.php | 23 +++ routes/api.php | 24 ++- tests/Feature/Api/DeviceEndpointsTest.php | 161 ++++++++++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 resources/views/default-screens/error.blade.php diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index cdfc9d2..fcd5f12 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -311,7 +311,7 @@ class ImageGenerationService public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep'])) { + if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { return null; } @@ -345,10 +345,10 @@ class ImageGenerationService /** * Generate a default screen image from Blade template */ - public static function generateDefaultScreenImage(Device $device, string $imageType): string + public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep'])) { + if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { throw new InvalidArgumentException("Invalid image type: {$imageType}"); } @@ -365,7 +365,7 @@ class ImageGenerationService $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); // Generate HTML from Blade template - $html = self::generateDefaultScreenHtml($device, $imageType); + $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName); // Create custom Browsershot instance if using AWS Lambda $browsershotInstance = null; @@ -445,12 +445,13 @@ class ImageGenerationService /** * Generate HTML from Blade template for default screens */ - private static function generateDefaultScreenHtml(Device $device, string $imageType): string + private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string { // Map image type to template name $templateName = match ($imageType) { 'setup-logo' => 'default-screens.setup', 'sleep' => 'default-screens.sleep', + 'error' => 'default-screens.error', default => throw new InvalidArgumentException("Invalid image type: {$imageType}") }; @@ -461,14 +462,22 @@ class ImageGenerationService $scaleLevel = $device->scaleLevel(); $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode - // Render the Blade template - return view($templateName, [ + // Build view data + $viewData = [ 'noBleed' => false, 'darkMode' => $darkMode, 'deviceVariant' => $deviceVariant, 'deviceOrientation' => $deviceOrientation, 'colorDepth' => $colorDepth, 'scaleLevel' => $scaleLevel, - ])->render(); + ]; + + // Add plugin name for error screens + if ($imageType === 'error' && $pluginName !== null) { + $viewData['pluginName'] = $pluginName; + } + + // Render the Blade template + return view($templateName, $viewData)->render(); } } diff --git a/resources/views/default-screens/error.blade.php b/resources/views/default-screens/error.blade.php new file mode 100644 index 0000000..be8063a --- /dev/null +++ b/resources/views/default-screens/error.blade.php @@ -0,0 +1,23 @@ +@props([ + 'noBleed' => false, + 'darkMode' => false, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '1bit', + 'scaleLevel' => null, + 'pluginName' => 'Recipe', +]) + + + + + + Error on {{ $pluginName }} + Unable to render content. Please check server logs. + + + + + diff --git a/routes/api.php b/routes/api.php index d1dbcac..b1d08b4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) { // Check and update stale data if needed if ($plugin->isDataStale() || $plugin->current_image === null) { $plugin->updateDataPayload(); - $markup = $plugin->render(device: $device); + try { + $markup = $plugin->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + } catch (Exception $e) { + Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); + // Generate error display + $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name); + $device->update(['current_screen_image' => $errorImageUuid]); + } } $plugin->refresh(); @@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) { } } - $markup = $playlistItem->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, null, $markup); + try { + $markup = $playlistItem->render(device: $device); + GenerateScreenJob::dispatchSync($device->id, null, $markup); + } catch (Exception $e) { + Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage()); + // For mashups, show error for the first plugin or a generic error + $firstPlugin = $plugins->first(); + $pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe'; + $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName); + $device->update(['current_screen_image' => $errorImageUuid]); + } $device->refresh(); diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index aff6758..2925a5e 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -7,6 +7,7 @@ use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; use App\Models\User; +use App\Services\ImageGenerationService; use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -1023,3 +1024,163 @@ test('screens endpoint matches MAC address case-insensitively', function (): voi $response->assertOk(); Queue::assertPushed(GenerateScreenJob::class); }); + +test('display endpoint handles plugin rendering errors gracefully', function (): void { + TrmnlPipeline::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create a plugin with Blade markup that will cause an exception when accessing data[0] + // when data is not an array or doesn't have index 0 + $plugin = Plugin::factory()->create([ + 'name' => 'Broken Recipe', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access + 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail if data[0] doesn't exist + 'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail + 'data_payload_updated_at' => now()->subMinutes(2), // Make it stale + 'current_image' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'test_playlist', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $plugin->id, + 'order' => 1, + 'is_active' => true, + 'last_displayed_at' => null, + ]); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify error screen was generated and set on device + $device->refresh(); + expect($device->current_screen_image)->not->toBeNull(); + + // Verify the error image exists + $errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png"); + // The TrmnlPipeline is faked, so we just verify the UUID was set + expect($device->current_screen_image)->toBeString(); +}); + +test('display endpoint handles mashup rendering errors gracefully', function (): void { + TrmnlPipeline::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create plugins for mashup, one with invalid markup + $plugin1 = Plugin::factory()->create([ + 'name' => 'Working Plugin', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + 'data_payload_updated_at' => now()->subMinutes(2), + 'current_image' => null, + ]); + + $plugin2 = Plugin::factory()->create([ + 'name' => 'Broken Plugin', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access + 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail + 'data_payload' => ['error' => 'Failed to fetch data'], + 'data_payload_updated_at' => now()->subMinutes(2), + 'current_image' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'test_playlist', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + // Create mashup playlist item + $playlistItem = PlaylistItem::createMashup( + $playlist, + '1Lx1R', + [$plugin1->id, $plugin2->id], + 'Test Mashup', + 1 + ); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify error screen was generated and set on device + $device->refresh(); + expect($device->current_screen_image)->not->toBeNull(); + + // Verify the error image UUID was set + expect($device->current_screen_image)->toBeString(); +}); + +test('generateDefaultScreenImage creates error screen with plugin name', function (): void { + TrmnlPipeline::fake(); + Storage::fake('public'); + Storage::disk('public')->makeDirectory('/images/generated'); + + $device = Device::factory()->create(); + + $errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name'); + + expect($errorUuid)->not->toBeEmpty(); + + // Verify the error image path would be created + $errorPath = "images/generated/{$errorUuid}.png"; + // Since TrmnlPipeline is faked, we just verify the UUID was generated + expect($errorUuid)->toBeString(); +}); + +test('generateDefaultScreenImage throws exception for invalid error image type', function (): void { + $device = Device::factory()->create(); + + expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type')) + ->toThrow(InvalidArgumentException::class); +}); + +test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void { + $device = new Device(); + $device->deviceModel = null; + + $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error'); + expect($result)->toBeNull(); +});