- @if($previewingRecipe && !empty($previewData))
-
- Preview {{ $previewData['name'] ?? 'Recipe' }}
-
-
-
+
+ @if($previewData['screenshot_url'])
-
- @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
-
-
+ @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'] }}
+
+
+ @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
+
+
- @endif
-
+
+ @endif
+
+@script
+
+@endscript
diff --git a/routes/api.php b/routes/api.php
index b1d08b4..d1dbcac 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -95,16 +95,9 @@ Route::get('/display', function (Request $request) {
// Check and update stale data if needed
if ($plugin->isDataStale() || $plugin->current_image === null) {
$plugin->updateDataPayload();
- try {
- $markup = $plugin->render(device: $device);
+ $markup = $plugin->render(device: $device);
- 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]);
- }
+ GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
}
$plugin->refresh();
@@ -127,17 +120,8 @@ Route::get('/display', function (Request $request) {
}
}
- 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]);
- }
+ $markup = $playlistItem->render(device: $device);
+ GenerateScreenJob::dispatchSync($device->id, null, $markup);
$device->refresh();
diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php
index 2925a5e..aff6758 100644
--- a/tests/Feature/Api/DeviceEndpointsTest.php
+++ b/tests/Feature/Api/DeviceEndpointsTest.php
@@ -7,7 +7,6 @@ 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;
@@ -1024,163 +1023,3 @@ 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 1b2efba..22ab4b6 100644
--- a/tests/Feature/Livewire/Catalog/IndexTest.php
+++ b/tests/Feature/Livewire/Catalog/IndexTest.php
@@ -65,46 +65,6 @@ 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 {
@@ -154,46 +114,3 @@ 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 a80c63a..ba1b722 100644
--- a/tests/Feature/Volt/CatalogTrmnlTest.php
+++ b/tests/Feature/Volt/CatalogTrmnlTest.php
@@ -28,33 +28,9 @@ 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)
@@ -176,111 +152,3 @@ 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);
-});