- @if($previewData['screenshot_url'])
+
+ @if($previewingRecipe && !empty($previewData))
+
+ Preview {{ $previewData['name'] ?? 'Recipe' }}
+
+
+
- @elseif($previewData['icon_url'])
-
-
![{{ $previewData['name'] }} icon]({{ $previewData['icon_url'] }})
-
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/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();
+});
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..a80c63a 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,111 @@ 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');
+});
+
+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);
+});