feat: recipes zip import support, add trmnlp compatible recipe configuration
Some checks are pending
tests / ci (push) Waiting to run

* recipes zip import support
* add trmnlp compatible recipe configuration
* support for multiple polling urls
This commit is contained in:
Benjamin Nussbaum 2025-06-13 12:23:52 +02:00
parent a927c0fb97
commit 414ca47cbf
17 changed files with 2409 additions and 125 deletions

View file

@ -0,0 +1,126 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
test('plugin import extracts default values from custom_fields and stores in configuration', function () {
// Create a user
$user = User::factory()->create();
// Test the functionality directly by creating a plugin with the expected configuration
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'reading_days',
'field_type' => 'string',
'name' => 'Reading Days',
'description' => 'Select days of the week to read',
'default' => 'Monday,Friday,Saturday,Sunday'
],
[
'keyname' => 'refresh_interval',
'field_type' => 'number',
'name' => 'Refresh Interval',
'description' => 'How often to refresh data',
'default' => 15
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone'
// No default value
]
]
];
// Extract default values from custom_fields and populate configuration
$configuration = [];
if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) {
foreach ($configurationTemplate['custom_fields'] as $field) {
if (isset($field['keyname']) && isset($field['default'])) {
$configuration[$field['keyname']] = $field['default'];
}
}
}
// Create the plugin directly
$plugin = Plugin::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin with Defaults',
'data_stale_minutes' => 30,
'data_strategy' => 'static',
'configuration_template' => $configurationTemplate,
'configuration' => $configuration,
]);
// Assert the plugin was created with correct configuration
expect($plugin)->not->toBeNull();
expect($plugin->configuration)->toBeArray();
expect($plugin->configuration)->toHaveKey('reading_days');
expect($plugin->configuration)->toHaveKey('refresh_interval');
expect($plugin->configuration)->not->toHaveKey('timezone');
expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday');
expect($plugin->getConfiguration('refresh_interval'))->toBe(15);
expect($plugin->getConfiguration('timezone'))->toBeNull();
// Verify configuration template was stored correctly
expect($plugin->configuration_template)->toBeArray();
expect($plugin->configuration_template['custom_fields'])->toHaveCount(3);
});
test('plugin import handles custom_fields without default values', function () {
// Create a user
$user = User::factory()->create();
// Test the functionality directly by creating a plugin with no default values
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone'
]
]
];
// Extract default values from custom_fields and populate configuration
$configuration = [];
if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) {
foreach ($configurationTemplate['custom_fields'] as $field) {
if (isset($field['keyname']) && isset($field['default'])) {
$configuration[$field['keyname']] = $field['default'];
}
}
}
// Create the plugin directly
$plugin = Plugin::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin No Defaults',
'data_stale_minutes' => 30,
'data_strategy' => 'static',
'configuration_template' => $configurationTemplate,
'configuration' => $configuration,
]);
// Assert the plugin was created with empty configuration
expect($plugin)->not->toBeNull();
expect($plugin->configuration)->toBeArray();
expect($plugin->configuration)->toBeEmpty();
// Verify configuration template was stored correctly
expect($plugin->configuration_template)->toBeArray();
expect($plugin->configuration_template['custom_fields'])->toHaveCount(1);
});

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
use App\Models\Plugin;
use App\Models\User;
use App\Services\PluginImportService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('local');
});
it('imports plugin from valid zip file', function () {
$user = User::factory()->create();
// Create a mock ZIP file with the required structure
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->user_id)->toBe($user->id)
->and($plugin->name)->toBe('Test Plugin')
->and($plugin->data_stale_minutes)->toBe(30)
->and($plugin->data_strategy)->toBe('static')
->and($plugin->markup_language)->toBe('liquid')
->and($plugin->configuration_template)->toHaveKey('custom_fields')
->and($plugin->configuration)->toHaveKey('api_key')
->and($plugin->configuration['api_key'])->toBe('default-api-key');
});
it('imports plugin with shared.liquid file', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
'src/shared.liquid' => '{% comment %}Shared styles{% endcomment %}',
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">');
});
it('imports plugin with files in root directory', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'settings.yml' => getValidSettingsYaml(),
'full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->name)->toBe('Test Plugin');
});
it('throws exception for invalid zip file', function () {
$user = User::factory()->create();
$zipFile = UploadedFile::fake()->createWithContent('invalid.zip', 'not a zip file');
$pluginImportService = new PluginImportService();
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Could not open the ZIP file.');
});
it('throws exception for missing required files', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
// Missing full.liquid
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
});
it('sets default values when settings are missing', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => "name: Minimal Plugin\n",
'src/full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->name)->toBe('Minimal Plugin')
->and($plugin->data_stale_minutes)->toBe(15) // default value
->and($plugin->data_strategy)->toBe('static') // default value
->and($plugin->polling_verb)->toBe('get'); // default value
});
it('handles blade markup language correctly', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.blade.php' => '<div>Blade template</div>',
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->markup_language)->toBe('blade');
});
// Helper methods
function createMockZipFile(array $files): string
{
$zip = new ZipArchive();
$tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
$zip->open($tempFile, ZipArchive::CREATE);
foreach ($files as $path => $content) {
$zip->addFromString($path, $content);
}
$zip->close();
$content = file_get_contents($tempFile);
unlink($tempFile);
return $content;
}
function getValidSettingsYaml(): string
{
return <<<'YAML'
name: Test Plugin
refresh_interval: 30
strategy: static
polling_verb: get
static_data: '{"test": "data"}'
custom_fields:
- keyname: api_key
field_type: text
default: default-api-key
label: API Key
YAML;
}
function getValidFullLiquid(): string
{
return <<<'LIQUID'
<div class="plugin-content">
<h1>{{ data.title }}</h1>
<p>{{ data.description }}</p>
</div>
LIQUID;
}

View file

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Plugin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PluginInlineTemplatesTest extends TestCase
{
use RefreshDatabase;
public function test_plugin_with_inline_templates(): void
{
$plugin = Plugin::factory()->create([
'name' => 'Test Plugin',
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% assign min = 1 %}
{% assign max = facts | size %}
{% assign diff = max | minus: min %}
{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %}
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% template title_bar %}
<div class="title_bar">
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
<span class="instance">{{ instance }}</span>
</div>
{% endtemplate %}
<div class="view view--{{ size }}">
{% render "session",
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
{% render "title_bar",
trmnl: trmnl,
instance: "Please try to enjoy each fact equally."
%}
</div>
LIQUID
,
'data_payload' => [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
],
]);
$result = $plugin->render('full');
// Should render both templates
// Check for any of the facts (since random number generation is non-deterministic)
$this->assertTrue(
str_contains($result, 'Fact 1') ||
str_contains($result, 'Fact 2') ||
str_contains($result, 'Fact 3')
);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
}
public function test_plugin_with_inline_templates_using_with_syntax(): void
{
$plugin = Plugin::factory()->create([
'name' => 'Test Plugin',
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% assign min = 1 %}
{% assign max = facts | size %}
{% assign diff = max | minus: min %}
{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %}
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% template title_bar %}
<div class="title_bar">
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
<span class="instance">{{ instance }}</span>
</div>
{% endtemplate %}
<div class="view view--{{ size }}">
{% render "session" with
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
{% render "title_bar" with
trmnl: trmnl,
instance: "Please try to enjoy each fact equally."
%}
</div>
LIQUID
,
'data_payload' => [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
],
]);
$result = $plugin->render('full');
// Should render both templates
// Check for any of the facts (since random number generation is non-deterministic)
$this->assertTrue(
str_contains($result, 'Fact 1') ||
str_contains($result, 'Fact 2') ||
str_contains($result, 'Fact 3')
);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
}
public function test_plugin_with_simple_inline_template(): void
{
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% template simple %}
<div class="simple">
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</div>
{% endtemplate %}
{% render "simple",
title: "Hello World",
content: "This is a test"
%}
LIQUID
,
]);
$result = $plugin->render('full');
$this->assertStringContainsString('Hello World', $result);
$this->assertStringContainsString('This is a test', $result);
$this->assertStringContainsString('class="simple"', $result);
}
}

View file

@ -0,0 +1,218 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('hasMissingRequiredConfigurationFields returns true when required field is null', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone',
'optional' => true // Marked as optional
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'timezone' => 'UTC' // Only timezone is set, api_key is missing
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone',
'optional' => true // Marked as optional
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'api_key' => 'test-api-key', // Required field is set
'timezone' => 'UTC'
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function () {
$user = User::factory()->create();
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => [],
'configuration' => []
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'api_key' => null // Explicitly set to null
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'api_key' => '' // Empty string
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'selected_items',
'field_type' => 'select',
'name' => 'Selected Items',
'description' => 'Select items',
'multiple' => true,
// Not marked as optional, so it's required
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'selected_items' => [] // Empty array
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'author_bio',
'name' => 'About This Plugin',
'field_type' => 'author_bio',
],
[
'keyname' => 'plugin_field',
'name' => 'Field Name',
'field_type' => 'string',
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'plugin_field' => 'set' // Required field is set
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
test('hasMissingRequiredConfigurationFields returns false when field has default value', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
'default' => 'default-api-key' // Has default value
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [] // Empty configuration, but field has default
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});