Harden automations module: full-post generation, reliable runs, editor UX

Generation
- Generate node now produces the full post (text + AI image + carousel)
  via a shared PostImagePipeline extracted from StreamPostCreation
- Generate config UI mirrors the /posts/create wizard (carousel slide
  count, include-image toggle); drop the decorative format/unsplash keys

Flow correctness
- RSS/HTTP nodes expose named has-items (default) and no-items output
  handles, labeled and colored like the Condition node
- AdvanceAutomationRun records a no_matching_edge terminal instead of
  completing silently; "0 new items" feedback in the test panel
- Manual/test runs no longer persist the production dedup watermark

Run reliability
- Pause truly halts in-flight runs (production only; manual test runs
  always run regardless of automation status)
- ProcessAutomationNode::failed() marks the run failed
- automation:recover-stuck-runs and automation:prune-dry-runs commands

Webhook / HTTP
- Branded User-Agent (config-driven) on outbound webhook + http_request
- Webhook fails on invalid JSON instead of silently sending {}
- HTTP custom headers editor; CodeMirror-based CodeEditor for JSON

Editor UX
- Header Test button only opens the panel; the panel has a Run button
  (saves first) and owns the with-real-data toggle
- Clicking a node closes the test panel and opens its config
- Node cards: max-width + truncate so long URLs don't grow the node
This commit is contained in:
Paulo Castellano 2026-06-10 20:45:01 -03:00
parent af4d83190e
commit 4efaa0bf99
40 changed files with 1898 additions and 231 deletions

View file

@ -30,6 +30,8 @@
*/
class RunFetchRssNode
{
private const ITEM_HANDLE = 'default';
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$feedUrl = (string) data_get($config, 'feed_url', '');
@ -68,7 +70,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
[$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark);
if ($state !== null && $newestSeen !== null) {
if ($state !== null && $newestSeen !== null && ! $run->is_manual) {
$state->update(['data' => array_merge($state->data ?? [], [
'last_item_date' => $newestSeen->toIso8601String(),
])]);
@ -167,7 +169,7 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
{
$connection = collect($run->automation->connections ?? [])
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default');
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE);
return $connection['target'] ?? null;
}

View file

@ -15,6 +15,7 @@
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use App\Services\Automation\ExpressionResolver;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Facades\Log;
class RunGenerateNode
@ -100,6 +101,18 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
];
}
$includeImage = (bool) data_get($config, 'include_image', true);
$brandAccount = $platforms !== []
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
: null;
$contentType = $platforms !== []
? ContentType::tryFrom((string) data_get($platforms[0], 'content_type'))
: null;
$imageCount = $this->intendedImageCount($format, $slideCount, $includeImage, $structured, $brandAccount);
// Dry runs do the AI work (so the user sees a real generation) but
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
// and skip their persistence too.
@ -109,13 +122,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
'post_id' => null,
'content' => $content,
'dry_run' => true,
'image_count' => $imageCount,
],
]);
}
$media = [];
if ($brandAccount) {
if ($format === 'carousel') {
$media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType);
} elseif ($includeImage) {
$media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType);
}
}
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => [],
'media' => $media,
'platforms' => $platforms,
]);
@ -239,6 +263,28 @@ public function deriveFormat(array $accountsConfig, array $config): array
return ['format' => 'single', 'slide_count' => 1];
}
/**
* Number of images that would be attached for the resolved format. Used as
* the dry-run indicator and mirrors the non-dry image generation branches:
* one per slide for carousels, one for single posts when images are enabled.
*
* @param array<string, mixed> $structured
*/
private function intendedImageCount(string $format, int $slideCount, bool $includeImage, array $structured, ?SocialAccount $brandAccount): int
{
if (! $brandAccount) {
return 0;
}
if ($format === 'carousel') {
$slides = data_get($structured, 'slides', []);
return is_array($slides) ? count($slides) : $slideCount;
}
return $includeImage ? 1 : 0;
}
private function resolveUser(AutomationRun $run): User
{
if ($run->automation->user_id) {

View file

@ -33,6 +33,8 @@
*/
class RunHttpRequestNode
{
private const ITEM_HANDLE = 'default';
public function __construct(private ExpressionResolver $resolver) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
@ -130,7 +132,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
$newItems[] = array_merge($item, ['_key' => $key]);
}
if ($useWatermark && $newestSeen !== null && $state !== null) {
if ($useWatermark && $newestSeen !== null && $state !== null && ! $run->is_manual) {
$state->update(['data' => array_merge($state->data ?? [], [
'last_item_date' => $newestSeen->toIso8601String(),
])]);
@ -194,7 +196,7 @@ private function buildRequest(array $config, array $context): PendingRequest
$request = $request->withHeaders($headers);
}
return $request;
return $request->withUserAgent(config('trypost.user_agent'));
}
/**
@ -252,7 +254,7 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
{
$connection = collect($run->automation->connections ?? [])
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default');
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE);
return $connection['target'] ?? null;
}

View file

@ -24,9 +24,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
}
$payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []);
$payload = json_decode($payloadJson, true) ?? [];
$trimmedPayload = trim($payloadJson);
if ($trimmedPayload !== '' && $trimmedPayload !== 'null') {
$decoded = json_decode($payloadJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [
'reason' => 'invalid_payload_json',
]);
}
$payload = $decoded ?? [];
} else {
$payload = [];
}
$response = Http::withHeaders($headers)
->withUserAgent(config('trypost.user_agent'))
->send($method, $url, ['json' => $payload]);
if ($response->serverError()) {

View file

@ -22,6 +22,11 @@ public function __invoke(AutomationRun $run, string $fromNodeId, string $handle
'status' => Status::Completed,
'finished_at' => now(),
'current_node_id' => null,
'error' => [
'reason' => 'no_matching_edge',
'handle' => $handle,
'node_id' => $fromNodeId,
],
]);
return;

View file

@ -20,8 +20,9 @@
*
* When `$withRealData` is false (the default), the run is marked `is_dry_run`
* so side-effectful nodes (publish, generate, watermark advancement, sibling
* spawning) short-circuit. The run row is auto-deleted after reaching a
* terminal state dry tests intentionally leave no trace.
* spawning) short-circuit. Dry-run rows are kept briefly so the editor test
* panel can show the completed result, then reaped by the scheduled
* `automation:prune-dry-runs` command once past its grace window.
*/
class TestAutomation
{

View file

@ -6,6 +6,7 @@
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\Enums\Automation\Run\Status;
use App\Enums\Automation\Status as AutomationStatus;
use App\Models\AutomationRun;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
@ -21,6 +22,9 @@ public function handle(AdvanceAutomationRun $advance): int
AutomationRun::query()
->where('status', Status::Waiting)
->where('next_action_at', '<=', now())
->where(fn ($query) => $query
->where('is_manual', true)
->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active)))
->lockForUpdate()
->chunkById(50, function ($runs) use ($advance) {
DB::transaction(function () use ($runs, $advance) {

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Models\AutomationRun;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('automation:prune-dry-runs')]
#[Description('Delete terminal dry-run automation runs older than the test-panel grace window')]
class PruneDryRunAutomationRuns extends Command
{
private const GRACE_MINUTES = 10;
public function handle(): int
{
$count = AutomationRun::query()
->where('is_dry_run', true)
->whereNotNull('finished_at')
->where('finished_at', '<=', now()->subMinutes(self::GRACE_MINUTES))
->delete();
$this->info("Pruned {$count} dry-run automation runs.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Enums\Automation\Run\Status;
use App\Models\AutomationRun;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('automation:recover-stuck-runs')]
#[Description('Fail automation runs stuck running or pending for more than 1 hour')]
class RecoverStuckAutomationRuns extends Command
{
public function handle(): int
{
$count = AutomationRun::query()
->whereIn('status', [Status::Running, Status::Pending])
->where('updated_at', '<=', now()->subHour())
->update([
'status' => Status::Failed,
'error' => ['reason' => 'stuck'],
'finished_at' => now(),
]);
$this->info("Recovered {$count} stuck automation runs.");
return self::SUCCESS;
}
}

View file

@ -7,8 +7,6 @@
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Media\Source;
use App\Enums\Media\Type as MediaType;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\PostPlatform\ContentType;
@ -18,17 +16,14 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\AiImageClient;
use App\Services\Ai\RecordAiUsage;
use App\Services\Image\BrandColorMapper;
use App\Services\Image\TemplateImageGenerator;
use App\Services\Image\PostImagePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class StreamPostCreation implements ShouldQueue
{
@ -101,32 +96,6 @@ public function handle(): void
}
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
* image_keywords and slide order/count). Failures are logged and the
* original structure is returned so generation never breaks because of the
* polish step.
*
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
/**
* Look up the AI image dimensions for the current format. Falls back to
* the generator's defaults (4:5 portrait) if the format string isn't a
* known ContentType case.
*
* @return array{width: int, height: int}
*/
private function dimensionsForFormat(): array
{
$type = $this->resolvedContentType();
return $type
? $type->aiImageDimensions()
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
}
/**
* The stored content type for the requested generation format. The carousel
* generation format is persisted as an Instagram feed post.
@ -140,6 +109,16 @@ private function resolvedContentType(): ?ContentType
return ContentType::tryFrom($this->format);
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
* image_keywords and slide order/count). Failures are logged and the
* original structure is returned so generation never breaks because of the
* polish step.
*
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format): array
{
try {
@ -207,29 +186,16 @@ private function humanize(Workspace $workspace, array $structured, string $forma
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$caption = (string) data_get($structured, 'caption', '');
$slides = data_get($structured, 'slides', []);
$media = [];
if ($socialAccount) {
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
foreach ($slides as $slide) {
$rendered = $generator->render(
workspace: $workspace,
socialAccount: $socialAccount,
title: data_get($slide, 'title', ''),
body: data_get($slide, 'body', ''),
imageKeywords: data_get($slide, 'image_keywords', []),
width: $width,
height: $height,
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
$media = app(PostImagePipeline::class)->forCarousel(
workspace: $workspace,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
}
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
@ -246,29 +212,16 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
$supportsCaption = $contentType?->supportsCaption() ?? true;
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
$imageTitle = (string) data_get($structured, 'image_title', '');
$imageBody = (string) data_get($structured, 'image_body', '');
$keywords = data_get($structured, 'image_keywords', []);
$media = [];
if ($this->imageCount > 0 && $socialAccount) {
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
$rendered = $generator->render(
$media = app(PostImagePipeline::class)->forSingle(
workspace: $workspace,
socialAccount: $socialAccount,
title: $imageTitle,
body: $imageBody,
imageKeywords: $keywords,
width: $width,
height: $height,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
$caption = $supportsCaption ? $rawContent : '';
@ -345,31 +298,4 @@ private function aspectRatioFor(ContentType $type): ?string
default => null,
};
}
/**
* @param array{path: string, source_meta: array<string, mixed>} $rendered
* @return array<string, mixed>
*/
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
{
$media = $workspace->media()->create([
'collection' => 'ai-generated',
'type' => MediaType::Image,
'path' => $rendered['path'],
'original_filename' => basename($rendered['path']),
'mime_type' => 'image/webp',
'size' => Storage::size($rendered['path']),
'order' => 0,
]);
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'source' => Source::Ai->value,
'source_meta' => $rendered['source_meta'],
];
}
}

View file

@ -17,6 +17,7 @@
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\NodeRun\Status as NodeRunStatus;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Enums\Automation\Status as AutomationStatus;
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
use Illuminate\Bus\Queueable;
@ -48,6 +49,10 @@ public function handle(AdvanceAutomationRun $advance): void
return;
}
if (! $this->run->is_manual && $this->run->automation->status !== AutomationStatus::Active) {
return;
}
$node = collect($this->run->automation->nodes ?? [])->firstWhere('id', $this->nodeId);
if ($node === null) {
@ -116,6 +121,21 @@ public function handle(AdvanceAutomationRun $advance): void
$advance($this->run, $this->nodeId, $result->nextHandle);
}
public function failed(?Throwable $e): void
{
$this->run->refresh();
if (in_array($this->run->status, [RunStatus::Completed, RunStatus::Failed], true)) {
return;
}
$this->run->update([
'status' => RunStatus::Failed,
'error' => ['message' => $e?->getMessage() ?? 'job failed', 'node_id' => $this->nodeId],
'finished_at' => now(),
]);
}
private function executeNode(NodeType $type, array $config): NodeRunResult
{
$handler = match ($type) {

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Services\Image;
use App\Enums\Media\Source;
use App\Enums\Media\Type as MediaType;
use App\Enums\PostPlatform\ContentType;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
class PostImagePipeline
{
public function __construct(
private TemplateImageGenerator $generator,
) {}
/**
* Render the single AI image for a structured generator output. Returns a
* one-element media-item array when an image is produced, or an empty array
* when the generator renders nothing.
*
* @param array<string, mixed> $structured
* @return array<int, array<string, mixed>>
*/
public function forSingle(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array
{
['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType);
$rendered = $this->generator->render(
workspace: $workspace,
socialAccount: $account,
title: (string) data_get($structured, 'image_title', ''),
body: (string) data_get($structured, 'image_body', ''),
imageKeywords: data_get($structured, 'image_keywords', []),
width: $width,
height: $height,
);
if (! $rendered) {
return [];
}
return [$this->buildAiMediaItem($workspace, $rendered)];
}
/**
* Render one AI image per slide in the structured carousel output. Slides
* that render nothing are skipped.
*
* @param array<string, mixed> $structured
* @return array<int, array<string, mixed>>
*/
public function forCarousel(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array
{
['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType);
$media = [];
foreach (data_get($structured, 'slides', []) as $slide) {
$rendered = $this->generator->render(
workspace: $workspace,
socialAccount: $account,
title: (string) data_get($slide, 'title', ''),
body: (string) data_get($slide, 'body', ''),
imageKeywords: data_get($slide, 'image_keywords', []),
width: $width,
height: $height,
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
return $media;
}
/**
* Resolve the AI image dimensions for the given content type, falling back
* to the generator defaults (4:5 portrait) when no content type is known.
*
* @return array{width: int, height: int}
*/
private function dimensionsForContentType(?ContentType $contentType): array
{
return $contentType
? $contentType->aiImageDimensions()
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
}
/**
* @param array{path: string, source_meta: array<string, mixed>} $rendered
* @return array<string, mixed>
*/
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
{
$media = $workspace->media()->create([
'collection' => 'ai-generated',
'type' => MediaType::Image,
'path' => $rendered['path'],
'original_filename' => basename($rendered['path']),
'mime_type' => 'image/webp',
'size' => Storage::size($rendered['path']),
'order' => 0,
]);
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'source' => Source::Ai->value,
'source_meta' => $rendered['source_meta'],
];
}
}

View file

@ -59,6 +59,19 @@
|
*/
/*
|--------------------------------------------------------------------------
| Outbound User-Agent
|--------------------------------------------------------------------------
|
| Branded User-Agent applied to outbound HTTP from automation nodes
| (webhook + http_request) so recipients know the request came from
| TryPost.it. Self-hosters can override it.
|
*/
'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'),
'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false),
'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false),

View file

@ -31,8 +31,11 @@
'node_input' => 'Input',
'node_output' => 'Output',
'node_error' => 'Error',
'no_new_items' => 'No new items — nothing downstream ran.',
'error_starting' => 'Could not start the test run.',
'with_real_data' => 'With real data',
'run' => 'Run test',
'idle_hint' => 'Hit Run test to execute the automation end-to-end.',
'real_data_hint' => 'This test will publish posts, advance polling watermarks, and trigger external side effects.',
'dry_badge' => 'Dry run',
],
@ -90,10 +93,15 @@
'end_summary' => 'Stops the automation here',
'fetch_rss' => 'Fetch RSS',
'http_request' => 'HTTP Request',
'handles' => [
'items' => 'has items',
'no_items' => 'no items',
],
],
'config' => [
'select_placeholder' => 'Select…',
'invalid_json' => 'This isnt valid JSON yet.',
'trigger' => [
'type' => 'Trigger type',
@ -146,14 +154,10 @@
'generate' => [
'social_accounts' => 'Social accounts',
'social_accounts_empty' => 'No connected social accounts. Connect one first.',
'target_slide_count' => 'Slides to generate (for carousel-capable platforms)',
'target_slide_count' => 'Slides to generate',
'prompt_template' => 'Prompt template',
'image_source' => 'Image source',
'image_sources' => [
'ai' => 'AI generated',
'unsplash' => 'Unsplash',
'none' => 'No image',
],
'include_image' => 'Include image',
'include_image_hint' => 'Generate an AI image for this post',
],
'delay' => [
'duration' => 'Duration',
@ -216,6 +220,10 @@
'api_key_header' => 'Header name',
'api_key_value' => 'API key',
'body_template' => 'Body template (JSON)',
'headers' => 'Headers',
'header_name' => 'Header name',
'header_value' => 'Value',
'add_header' => 'Add header',
'polling_section' => 'Polling (optional)',
'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.',
'items_path' => 'Items path',
@ -244,6 +252,7 @@
'only_failed_can_retry' => 'Only failed runs can be retried.',
'no_generated_post' => 'No generated post found on run.',
'webhook_server_error' => 'Webhook server error.',
'webhook_invalid_payload_json' => 'The payload template is not valid JSON.',
'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.',
'no_trigger_connection' => 'No node connected to the Trigger node.',
],

View file

@ -31,8 +31,11 @@
'node_input' => 'Entrada',
'node_output' => 'Salida',
'node_error' => 'Error',
'no_new_items' => 'Sin elementos nuevos — no se ejecutó nada después.',
'error_starting' => 'No se pudo iniciar la ejecución de prueba.',
'with_real_data' => 'Con datos reales',
'run' => 'Ejecutar prueba',
'idle_hint' => 'Pulsa Ejecutar prueba para correr la automatización de principio a fin.',
'real_data_hint' => 'Esta prueba publicará posts, avanzará marcadores de polling y disparará efectos secundarios externos.',
'dry_badge' => 'Prueba seca',
],
@ -90,10 +93,15 @@
'end_summary' => 'Termina la automatización aquí',
'fetch_rss' => 'Obtener RSS',
'http_request' => 'Petición HTTP',
'handles' => [
'items' => 'con elementos',
'no_items' => 'sin elementos',
],
],
'config' => [
'select_placeholder' => 'Selecciona…',
'invalid_json' => 'Esto aún no es un JSON válido.',
'trigger' => [
'type' => 'Tipo de disparador',
@ -146,14 +154,10 @@
'generate' => [
'social_accounts' => 'Cuentas sociales',
'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.',
'target_slide_count' => 'Diapositivas a generar (para plataformas con carrusel)',
'target_slide_count' => 'Diapositivas a generar',
'prompt_template' => 'Plantilla de prompt',
'image_source' => 'Fuente de imagen',
'image_sources' => [
'ai' => 'Generada con IA',
'unsplash' => 'Unsplash',
'none' => 'Sin imagen',
],
'include_image' => 'Incluir imagen',
'include_image_hint' => 'Generar una imagen con IA para esta publicación',
],
'delay' => [
'duration' => 'Duración',
@ -216,6 +220,10 @@
'api_key_header' => 'Nombre del header',
'api_key_value' => 'API key',
'body_template' => 'Plantilla del body (JSON)',
'headers' => 'Headers',
'header_name' => 'Nombre del header',
'header_value' => 'Valor',
'add_header' => 'Agregar header',
'polling_section' => 'Polling (opcional)',
'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.',
'items_path' => 'Ruta de ítems',
@ -244,6 +252,7 @@
'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.',
'no_generated_post' => 'No se encontró un post generado en la ejecución.',
'webhook_server_error' => 'Error del servidor del webhook.',
'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.',
'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.',
'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.',
],

View file

@ -31,8 +31,11 @@
'node_input' => 'Entrada',
'node_output' => 'Saída',
'node_error' => 'Erro',
'no_new_items' => 'Nenhum item novo — nada foi executado adiante.',
'error_starting' => 'Não foi possível iniciar a execução de teste.',
'with_real_data' => 'Com dados reais',
'run' => 'Rodar teste',
'idle_hint' => 'Clique em Rodar teste para executar a automação de ponta a ponta.',
'real_data_hint' => 'Este teste vai publicar posts, avançar watermarks e disparar efeitos colaterais externos.',
'dry_badge' => 'Teste seco',
],
@ -90,10 +93,15 @@
'end_summary' => 'Encerra a automação aqui',
'fetch_rss' => 'Buscar RSS',
'http_request' => 'Requisição HTTP',
'handles' => [
'items' => 'tem itens',
'no_items' => 'sem itens',
],
],
'config' => [
'select_placeholder' => 'Selecione…',
'invalid_json' => 'Isto ainda não é um JSON válido.',
'trigger' => [
'type' => 'Tipo de trigger',
@ -146,14 +154,10 @@
'generate' => [
'social_accounts' => 'Contas sociais',
'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.',
'target_slide_count' => 'Slides a gerar (para plataformas com carrossel)',
'target_slide_count' => 'Slides a gerar',
'prompt_template' => 'Template do prompt',
'image_source' => 'Origem da imagem',
'image_sources' => [
'ai' => 'Gerada por IA',
'unsplash' => 'Unsplash',
'none' => 'Sem imagem',
],
'include_image' => 'Incluir imagem',
'include_image_hint' => 'Gerar uma imagem com IA para esta publicação',
],
'delay' => [
'duration' => 'Duração',
@ -216,6 +220,10 @@
'api_key_header' => 'Nome do header',
'api_key_value' => 'API key',
'body_template' => 'Template do body (JSON)',
'headers' => 'Headers',
'header_name' => 'Nome do header',
'header_value' => 'Valor',
'add_header' => 'Adicionar header',
'polling_section' => 'Polling (opcional)',
'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.',
'items_path' => 'Caminho dos itens',
@ -244,6 +252,7 @@
'only_failed_can_retry' => 'Apenas execuções que falharam podem ser repetidas.',
'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.',
'webhook_server_error' => 'Erro no servidor do webhook.',
'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.',
'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.',
'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.',
],

170
package-lock.json generated
View file

@ -5,6 +5,10 @@
"packages": {
"": {
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.17",
"@inertiajs/vue3": "^3.0.0",
"@tabler/icons-vue": "^3.36.1",
"@tailwindcss/typography": "^0.5.19",
@ -16,6 +20,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.19",
"embla-carousel-vue": "^8.6.0",
"highlight.js": "^11.11.1",
@ -214,6 +219,97 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@ -1188,6 +1284,41 @@
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
@ -1259,6 +1390,12 @@
"node": ">=6.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -4333,6 +4470,21 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4452,6 +4604,12 @@
"node": ">= 6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -9141,6 +9299,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
@ -9947,6 +10111,12 @@
"typescript": ">=5.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-vitals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",

View file

@ -38,6 +38,10 @@
"vue-tsc": "^2.2.4"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.17",
"@inertiajs/vue3": "^3.0.0",
"@tabler/icons-vue": "^3.36.1",
"@tailwindcss/typography": "^0.5.19",
@ -49,6 +53,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.19",
"embla-carousel-vue": "^8.6.0",
"highlight.js": "^11.11.1",

View file

@ -171,6 +171,7 @@ .trypost-container {
.automation-node {
position: relative;
min-width: 230px;
max-width: 260px;
background: var(--card);
border: 2px solid var(--foreground);
border-radius: 14px;
@ -185,6 +186,7 @@ .automation-node:hover {
.automation-node--wide {
min-width: 260px;
max-width: 260px;
}
.automation-node.is-selected {
@ -233,11 +235,15 @@ .automation-node--accent-slate .automation-node__header { background: #f1f5f9;
.automation-node--accent-zinc .automation-node__header { background: #f4f4f5; }
.automation-node__title {
min-width: 0;
font-weight: 700;
font-size: 0.875rem;
color: var(--foreground);
line-height: 1.2;
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.automation-node__summary {

View file

@ -0,0 +1,159 @@
<script setup lang="ts">
import { indentWithTab } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import debounce from '@/debounce';
const props = withDefaults(
defineProps<{
modelValue: string;
language?: 'json';
readOnly?: boolean;
placeholder?: string;
}>(),
{
language: 'json',
readOnly: false,
placeholder: '',
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const editorContainer = ref<HTMLElement>();
let view: EditorView | null = null;
const debouncedEmit = debounce((value: string) => {
emit('update:modelValue', value);
}, 250);
const languageExtension = () => {
switch (props.language) {
case 'json':
default:
return json();
}
};
const lightTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '13px',
color: 'var(--foreground)',
backgroundColor: 'var(--card)',
border: '2px solid var(--foreground)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
},
'&.cm-focused': {
outline: '2px solid var(--ring)',
outlineOffset: '0px',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'var(--font-mono)',
lineHeight: '1.6',
},
'.cm-content': {
caretColor: 'var(--foreground)',
padding: '8px 0',
},
'.cm-gutters': {
backgroundColor: 'var(--muted)',
color: 'var(--muted-foreground)',
border: 'none',
borderRight: '2px solid color-mix(in srgb, var(--foreground) 15%, transparent)',
},
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--foreground) 4%, transparent)',
},
'.cm-activeLineGutter': {
backgroundColor: 'color-mix(in srgb, var(--foreground) 8%, transparent)',
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor: 'color-mix(in srgb, var(--ring) 20%, transparent)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--foreground)',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
},
});
onMounted(() => {
if (!editorContainer.value) {
return;
}
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
debouncedEmit(update.state.doc.toString());
}
});
const extensions = [
basicSetup,
keymap.of([indentWithTab]),
languageExtension(),
EditorView.lineWrapping,
lightTheme,
updateListener,
];
if (props.placeholder) {
extensions.push(placeholderExt(props.placeholder));
}
if (props.readOnly) {
extensions.push(EditorState.readOnly.of(true));
extensions.push(EditorView.editable.of(false));
}
view = new EditorView({
state: EditorState.create({
doc: props.modelValue ?? '',
extensions,
}),
parent: editorContainer.value,
});
});
watch(
() => props.modelValue,
(value) => {
if (!view) {
return;
}
const current = view.state.doc.toString();
if ((value ?? '') !== current) {
view.dispatch({
changes: {
from: 0,
to: current.length,
insert: value ?? '',
},
});
}
},
);
onBeforeUnmount(() => {
debouncedEmit.cancel();
view?.destroy();
view = null;
});
</script>
<template>
<div ref="editorContainer" class="code-editor h-full w-full" />
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconLoader2, IconX } from '@tabler/icons-vue';
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconInfoCircle, IconLoader2, IconPlayerPlay, IconX } from '@tabler/icons-vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { trans } from 'laravel-vue-i18n';
@ -7,6 +7,7 @@ import { trans } from 'laravel-vue-i18n';
import JsonViewer from '@/components/JsonViewer.vue';
import { useAutomationEcho } from '@/composables/echo/useAutomationEcho';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { test as testAutomation } from '@/routes/app/automations';
import { showRun as showRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
@ -32,11 +33,12 @@ interface Run {
is_dry_run: boolean;
}
const props = defineProps<{ automationId: string; withRealData?: boolean }>();
const props = defineProps<{ automationId: string; beforeRun?: () => Promise<boolean> | boolean }>();
const open = defineModel<boolean>('open', { default: false });
const isStarting = ref(false);
const realData = ref(false);
const run = ref<Run | null>(null);
const nodeRuns = ref<NodeRun[]>([]);
const activeRunId = ref<string | null>(null);
@ -83,7 +85,7 @@ const start = async () => {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '',
},
body: JSON.stringify({ with_real_data: props.withRealData ?? false }),
body: JSON.stringify({ with_real_data: realData.value }),
});
if (!response.ok) {
throw new Error('start failed');
@ -99,20 +101,15 @@ const start = async () => {
}
};
// Parent triggers runs imperatively via the template ref so every click on the
// Test button kicks off a fresh execution including the first one, since the
// parent calls `start()` right after toggling the panel open.
defineExpose({ start });
const runTest = async () => {
if (isStarting.value) return;
const proceed = await (props.beforeRun?.() ?? true);
if (proceed === false) return;
await start();
};
const close = () => { open.value = false; };
const runStatusIcon = (status: string) => {
if (status === 'completed') return IconCircleCheck;
if (status === 'failed') return IconAlertCircle;
if (status === 'running') return IconLoader2;
return IconCircleDot;
};
const statusLabel = (status: string): string => {
const map: Record<string, string> = {
running: trans('automations.test.in_progress'),
@ -129,6 +126,16 @@ const nodeStatusIcon = (status: string) => {
if (status === 'running') return IconLoader2;
return IconCircleDot;
};
// Fetch nodes (RSS / HTTP) short-circuit via the `no_items` handle with an
// output of `{ fetch: { count: 0 } }` when nothing new arrived. Surface that
// as an explicit note instead of an uninformative empty JSON blob.
const isZeroFetchResult = (nodeRun: NodeRun): boolean => {
if (nodeRun.status !== 'completed') return false;
if (nodeRun.node_type !== 'fetch_rss' && nodeRun.node_type !== 'http_request') return false;
const fetch = nodeRun.output?.fetch as { count?: number } | undefined;
return fetch?.count === 0;
};
</script>
<template>
@ -153,28 +160,36 @@ const nodeStatusIcon = (status: string) => {
</Button>
</div>
<div class="flex items-center justify-between gap-3 rounded-xl border-2 border-foreground bg-card p-3 shadow-[3px_3px_0_var(--foreground)]">
<label class="flex cursor-pointer items-center gap-2 text-sm font-semibold text-foreground/70">
<Checkbox v-model="realData" :disabled="isStarting" />
{{ $t('automations.test.with_real_data') }}
</label>
<Button size="sm" :disabled="isStarting" @click="runTest">
<IconLoader2 v-if="isStarting" class="size-4 animate-spin" />
<IconPlayerPlay v-else class="size-4" />
{{ $t('automations.test.run') }}
</Button>
</div>
<div
v-if="run === null && !isStarting"
class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-8 text-center text-sm font-medium text-foreground/60"
>
{{ $t('automations.test.idle_hint') }}
</div>
<div v-if="run === null && isStarting" class="flex items-center gap-2.5 text-sm font-medium text-foreground/70">
<IconLoader2 class="size-5 animate-spin" />
{{ $t('automations.test.starting') }}
</div>
<div v-if="run" class="flex items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 shadow-[3px_3px_0_var(--foreground)]">
<div
:class="[
'inline-flex size-10 -rotate-3 shrink-0 items-center justify-center rounded-xl border-2 border-foreground shadow-2xs',
run.status === 'completed' && 'bg-emerald-200 text-emerald-900',
run.status === 'failed' && 'bg-rose-200 text-rose-900',
run.status === 'running' && 'bg-amber-200 text-amber-900',
!['completed', 'failed', 'running'].includes(run.status) && 'bg-zinc-200 text-zinc-900',
]"
>
<component :is="runStatusIcon(run.status)" :class="['size-5', run.status === 'running' && 'animate-spin']" stroke-width="2.5" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-black uppercase tracking-wider text-foreground/60">{{ $t('automations.test.title') }}</p>
<p class="text-base font-bold">{{ statusLabel(run.status) }}</p>
<p v-if="run.error" class="mt-1 text-sm text-rose-700">{{ run.error.message }}</p>
</div>
<div
v-if="run && run.status === 'failed' && run.error?.message"
class="flex items-start gap-2.5 rounded-xl border-2 border-rose-700 bg-rose-50 p-4 text-sm font-medium text-rose-800"
>
<IconAlertCircle class="mt-0.5 size-5 shrink-0" stroke-width="2.5" />
<span>{{ run.error.message }}</span>
</div>
<div v-if="run && nodeRuns.length === 0" class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-8 text-center text-sm font-medium text-foreground/60">
@ -211,7 +226,15 @@ const nodeStatusIcon = (status: string) => {
<span class="font-black uppercase text-xs tracking-wider">{{ $t('automations.test.node_error') }}:</span>
<span class="ml-1">{{ nodeRun.error.message }}</span>
</p>
<details v-if="nodeRun.output" class="group" :class="{ 'mt-3': nodeRun.error }">
<p
v-if="isZeroFetchResult(nodeRun)"
class="flex items-center gap-2 rounded-lg border-2 border-foreground/30 bg-card p-3 text-sm font-medium text-foreground/70"
:class="{ 'mt-3': nodeRun.error }"
>
<IconInfoCircle class="size-5 shrink-0 text-foreground/50" stroke-width="2.5" />
{{ $t('automations.test.no_new_items') }}
</p>
<details v-if="nodeRun.output && !isZeroFetchResult(nodeRun)" class="group" :class="{ 'mt-3': nodeRun.error }">
<summary class="flex cursor-pointer items-center gap-1.5 text-xs font-black uppercase tracking-wider text-foreground/60 hover:text-foreground">
<IconChevronRight class="size-4 transition-transform group-open:rotate-90" stroke-width="2.5" />
{{ $t('automations.test.node_output') }}

View file

@ -9,15 +9,9 @@ import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue';
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ContentType } from '@/types/content-type';
import { Platform } from '@/types/platform';
@ -50,9 +44,9 @@ interface GenerateAccount {
interface GenerateConfig {
accounts: GenerateAccount[];
target_slide_count?: number;
target_slide_count: number;
prompt_template: string;
image_source: 'ai' | 'unsplash' | 'none';
include_image: boolean;
}
const props = defineProps<{
@ -142,9 +136,9 @@ const normalizeAccountsFromData = (): GenerateAccount[] => {
const local = ref<GenerateConfig>({
accounts: normalizeAccountsFromData(),
target_slide_count: props.data.target_slide_count as number | undefined,
target_slide_count: (props.data.target_slide_count as number | undefined) ?? 5,
prompt_template: (props.data.prompt_template as string) ?? '',
image_source: (props.data.image_source as GenerateConfig['image_source']) ?? 'ai',
include_image: (props.data.include_image as boolean | undefined) ?? true,
});
watch(local, (val) => emit('update', val), { deep: true });
@ -308,9 +302,28 @@ const hasCarouselCapableAccount = computed(() =>
</template>
</div>
<div v-if="hasCarouselCapableAccount">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.target_slide_count') }}</label>
<Input type="number" v-model.number="local.target_slide_count" placeholder="5" />
<div v-if="hasCarouselCapableAccount" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.target_slide_count') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
:key="n"
type="button"
size="icon"
:variant="local.target_slide_count === n ? 'default' : 'outline'"
@click="local.target_slide_count = n"
>
{{ n }}
</Button>
</div>
</div>
<div v-else class="flex items-start justify-between gap-3">
<div class="space-y-0.5">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.include_image') }}</Label>
<p class="text-xs text-foreground/60">{{ $t('automations.config.generate.include_image_hint') }}</p>
</div>
<Switch v-model="local.include_image" />
</div>
<div>
@ -318,20 +331,5 @@ const hasCarouselCapableAccount = computed(() =>
<Textarea v-model="local.prompt_template" :rows="6" placeholder="Write a social media post about {{ trigger.title }}…" />
<InputError :message="errors?.prompt_template" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.image_source') }}</label>
<Select v-model="local.image_source">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ai">{{ $t('automations.config.generate.image_sources.ai') }}</SelectItem>
<SelectItem value="unsplash">{{ $t('automations.config.generate.image_sources.unsplash') }}</SelectItem>
<SelectItem value="none">{{ $t('automations.config.generate.image_sources.none') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.image_source" class="mt-1" />
</div>
</div>
</template>

View file

@ -1,7 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { IconPlus, IconTrash } from '@tabler/icons-vue';
import CodeEditor from '@/components/CodeEditor.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
@ -10,7 +14,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type AuthType = 'none' | 'bearer' | 'basic' | 'api_key';
@ -23,6 +26,7 @@ interface HttpRequestConfig {
auth_username: string;
auth_password: string;
auth_header_name: string;
headers: Record<string, string>;
body_template: string;
items_path: string;
item_key_path: string;
@ -43,16 +47,65 @@ const local = ref<HttpRequestConfig>({
auth_username: (props.data.auth_username as string) ?? '',
auth_password: (props.data.auth_password as string) ?? '',
auth_header_name: (props.data.auth_header_name as string) ?? 'X-API-Key',
headers: (props.data.headers as Record<string, string>) ?? {},
body_template: (props.data.body_template as string) ?? '',
items_path: (props.data.items_path as string) ?? '',
item_key_path: (props.data.item_key_path as string) ?? '',
item_date_path: (props.data.item_date_path as string) ?? '',
});
interface HeaderRow {
name: string;
value: string;
}
const headerRows = ref<HeaderRow[]>(
Object.entries((props.data.headers as Record<string, string>) ?? {}).map(([name, value]) => ({
name,
value: String(value),
})),
);
watch(
headerRows,
(rows) => {
const headers: Record<string, string> = {};
rows.forEach((row) => {
const name = row.name.trim();
if (name !== '') {
headers[name] = row.value;
}
});
local.value.headers = headers;
},
{ deep: true },
);
const addHeader = (): void => {
headerRows.value.push({ name: '', value: '' });
};
const removeHeader = (index: number): void => {
headerRows.value.splice(index, 1);
};
watch(local, (val) => emit('update', val), { deep: true });
const supportsBody = computed(() => ['POST', 'PUT', 'PATCH'].includes(local.value.method));
const isPollingMode = computed(() => local.value.items_path.trim() !== '');
const isBodyJsonInvalid = computed(() => {
const value = local.value.body_template.trim();
if (value === '') {
return false;
}
try {
JSON.parse(value);
return false;
} catch {
return true;
}
});
</script>
<template>
@ -129,10 +182,46 @@ const isPollingMode = computed(() => local.value.items_path.trim() !== '');
<div v-if="supportsBody">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.body_template') }}</label>
<Textarea v-model="local.body_template" :rows="5" placeholder='{"id": "{{ trigger.post.id }}"}' />
<div class="h-36">
<CodeEditor v-model="local.body_template" language="json" placeholder='{"id": "{{ trigger.post.id }}"}' />
</div>
<p v-if="isBodyJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
{{ $t('automations.config.invalid_json') }}
</p>
<InputError :message="errors?.body_template" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.headers') }}</label>
<div class="space-y-2">
<div v-for="(row, index) in headerRows" :key="index" class="flex items-center gap-2">
<Input
v-model="row.name"
class="flex-1"
:placeholder="$t('automations.config.http_request.header_name')"
/>
<Input
v-model="row.value"
class="flex-1"
:placeholder="$t('automations.config.http_request.header_value')"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="shrink-0 text-foreground/60 hover:text-destructive"
@click="removeHeader(index)"
>
<IconTrash class="size-4" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="mt-2" @click="addHeader">
<IconPlus class="size-4" />
{{ $t('automations.config.http_request.add_header') }}
</Button>
</div>
<div class="border-t-2 border-foreground/10 pt-4">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('automations.config.http_request.polling_section') }}

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import CodeEditor from '@/components/CodeEditor.vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
@ -10,7 +11,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
interface WebhookConfig {
url: string;
@ -33,6 +33,19 @@ const local = ref<WebhookConfig>({
});
watch(local, (val) => emit('update', val), { deep: true });
const isPayloadJsonInvalid = computed(() => {
const value = local.value.payload_template.trim();
if (value === '') {
return false;
}
try {
JSON.parse(value);
return false;
} catch {
return true;
}
});
</script>
<template>
@ -62,7 +75,12 @@ watch(local, (val) => emit('update', val), { deep: true });
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.payload_template') }}</label>
<Textarea v-model="local.payload_template" :rows="6" placeholder='{"content": "{{ post.content }}"}' />
<div class="h-40">
<CodeEditor v-model="local.payload_template" language="json" placeholder='{"content": "{{ post.content }}"}' />
</div>
<p v-if="isPayloadJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
{{ $t('automations.config.invalid_json') }}
</p>
<InputError :message="errors?.payload_template" class="mt-1" />
</div>
</div>

View file

@ -36,6 +36,21 @@ const summary = computed(() => {
{{ summary }}
</div>
<Handle type="target" :position="Position.Left" class="!bg-amber-500" />
<Handle type="source" :position="Position.Right" class="!bg-amber-500" />
<Handle
id="default"
type="source"
:position="Position.Right"
class="!bg-emerald-500"
:style="{ top: '35%' }"
/>
<span class="pointer-events-none absolute left-full top-[35%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700">{{ $t('automations.nodes.handles.items') }}</span>
<Handle
id="no_items"
type="source"
:position="Position.Right"
class="!bg-rose-500"
:style="{ top: '75%' }"
/>
<span class="pointer-events-none absolute left-full top-[75%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">{{ $t('automations.nodes.handles.no_items') }}</span>
</div>
</template>

View file

@ -40,6 +40,21 @@ const summary = computed(() => {
{{ summary }}
</div>
<Handle type="target" :position="Position.Left" class="!bg-slate-500" />
<Handle type="source" :position="Position.Right" class="!bg-slate-500" />
<Handle
id="default"
type="source"
:position="Position.Right"
class="!bg-emerald-500"
:style="{ top: '35%' }"
/>
<span class="pointer-events-none absolute left-full top-[35%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700">{{ $t('automations.nodes.handles.items') }}</span>
<Handle
id="no_items"
type="source"
:position="Position.Right"
class="!bg-rose-500"
:style="{ top: '75%' }"
/>
<span class="pointer-events-none absolute left-full top-[75%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">{{ $t('automations.nodes.handles.no_items') }}</span>
</div>
</template>

View file

@ -15,7 +15,7 @@ import {
IconWorld,
IconX,
} from '@tabler/icons-vue';
import { computed, markRaw, nextTick, ref, watch } from 'vue';
import { computed, markRaw, ref, watch } from 'vue';
import {
ConnectionMode,
MarkerType,
@ -34,7 +34,6 @@ import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import AppLayout from '@/layouts/AppLayout.vue';
import {
show as showAutomation,
@ -190,6 +189,7 @@ const {
} = useVueFlow();
onNodeClick(({ node }) => {
isTestPanelOpen.value = false;
selectedNodeId.value = node.id;
});
@ -269,7 +269,7 @@ const defaultConfigFor = (type: string): Record<string, unknown> => {
schedule_minute: 0,
schedule_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
case NodeType.Generate: return { accounts: [], format: 'single', prompt_template: '', image_source: 'ai' };
case NodeType.Generate: return { accounts: [], prompt_template: '', include_image: true, target_slide_count: 5 };
case NodeType.Delay: return { duration: 1, unit: 'hours' };
case NodeType.Condition: return { field: '', operator: 'contains', value: '' };
case NodeType.Publish: return { mode: 'now', scheduled_offset: 60 };
@ -343,15 +343,9 @@ const deleteSelectedNode = () => {
const isSaving = ref(false);
const isTestPanelOpen = ref(false);
const testWithRealData = ref(false);
const testPanelRef = ref<InstanceType<typeof TestRunPanel> | null>(null);
const handleTestClick = async () => {
const handleTestClick = () => {
isTestPanelOpen.value = true;
// Wait one tick when opening for the first time so v-if mounts the panel
// and the ref resolves before we invoke start().
await nextTick();
testPanelRef.value?.start();
};
const sanitizeNodes = (list: Node[]) =>
@ -374,27 +368,32 @@ const sanitizeEdges = (list: Edge[]) =>
return edge;
});
const save = () => {
if (isSaving.value) return;
isSaving.value = true;
router.put(
updateAutomation.url(props.automation.id),
{
name: name.value.trim() || props.automation.name,
nodes: sanitizeNodes(nodes.value),
connections: sanitizeEdges(edges.value),
},
{
preserveScroll: true,
onFinish: () => { isSaving.value = false; },
onSuccess: () => toast.success(trans('automations.form.save_success')),
onError: (errors: Record<string, string>) => {
const msg = (errors as any).message ?? trans('automations.form.save_error_fallback');
toast.error(msg);
const save = (): Promise<boolean> =>
new Promise((resolve) => {
if (isSaving.value) return resolve(false);
isSaving.value = true;
router.put(
updateAutomation.url(props.automation.id),
{
name: name.value.trim() || props.automation.name,
nodes: sanitizeNodes(nodes.value),
connections: sanitizeEdges(edges.value),
},
},
);
};
{
preserveScroll: true,
onFinish: () => { isSaving.value = false; },
onSuccess: () => {
toast.success(trans('automations.form.save_success'));
resolve(true);
},
onError: (errors: Record<string, string>) => {
const msg = (errors as any).message ?? trans('automations.form.save_error_fallback');
toast.error(msg);
resolve(false);
},
},
);
});
const closePanel = () => {
selectedNodeId.value = null;
@ -451,10 +450,6 @@ const defaultEdgeOptions = {
class="w-72 rounded-md border-2 border-transparent bg-transparent px-3 py-1 text-center text-sm font-semibold text-foreground transition-colors hover:border-foreground/15 focus:border-foreground focus:bg-background focus:outline-none"
/>
<div class="flex items-center justify-end gap-2">
<label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-foreground/70 hover:bg-foreground/5">
<Checkbox v-model="testWithRealData" />
{{ $t('automations.test.with_real_data') }}
</label>
<Button variant="outline" size="sm" @click="handleTestClick">{{ $t('automations.actions.test') }}</Button>
<Button size="sm" @click="save" :disabled="isSaving">{{ $t('automations.actions.save') }}</Button>
</div>
@ -522,10 +517,9 @@ const defaultEdgeOptions = {
<TestRunPanel
v-if="isTestPanelOpen"
ref="testPanelRef"
v-model:open="isTestPanelOpen"
:automation-id="automation.id"
:with-real-data="testWithRealData"
:before-run="save"
/>
<aside

View file

@ -4,6 +4,8 @@
use App\Console\Commands\Automation\FireScheduleTriggers;
use App\Console\Commands\Automation\ProcessAutomationDelays;
use App\Console\Commands\Automation\PruneDryRunAutomationRuns;
use App\Console\Commands\Automation\RecoverStuckAutomationRuns;
use App\Console\Commands\CheckSocialConnections;
use App\Console\Commands\ProcessScheduledPosts;
use App\Console\Commands\RecoverStuckPosts;
@ -16,3 +18,5 @@
Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer();
Schedule::command(FireScheduleTriggers::class)->everyMinute()->withoutOverlapping()->onOneServer();
Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer();
Schedule::command(RecoverStuckAutomationRuns::class)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
Schedule::command(PruneDryRunAutomationRuns::class)->everyTenMinutes()->withoutOverlapping()->onOneServer();

View file

@ -11,7 +11,7 @@
it('wakes runs whose next_action_at is in the past', function () {
Queue::fake();
$automation = Automation::factory()->create([
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => []],

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
it('prunes terminal dry-run rows older than the grace window', function () {
$old = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => now()->subHour()]);
$recent = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => now()->subMinute()]);
$realOld = AutomationRun::factory()->create(['is_dry_run' => false, 'finished_at' => now()->subHour()]);
$this->artisan('automation:prune-dry-runs')->assertExitCode(0);
expect(AutomationRun::find($old->id))->toBeNull();
expect(AutomationRun::find($recent->id))->not->toBeNull();
expect(AutomationRun::find($realOld->id))->not->toBeNull();
});
it('leaves dry-run rows that have not finished yet', function () {
$unfinished = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => null, 'updated_at' => now()->subHour()]);
$this->artisan('automation:prune-dry-runs')->assertExitCode(0);
expect(AutomationRun::find($unfinished->id))->not->toBeNull();
});
it('deletes the node runs belonging to pruned dry runs', function () {
$old = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => now()->subHour()]);
$nodeRun = AutomationNodeRun::factory()->create(['run_id' => $old->id]);
$this->artisan('automation:prune-dry-runs')->assertExitCode(0);
expect(AutomationRun::find($old->id))->toBeNull();
expect(AutomationNodeRun::find($nodeRun->id))->toBeNull();
});

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Enums\Automation\Run\Status as RunStatus;
use App\Models\AutomationRun;
it('fails runs stuck running past the threshold', function () {
$stuck = AutomationRun::factory()->create(['status' => RunStatus::Running, 'started_at' => now()->subHours(3), 'updated_at' => now()->subHours(3)]);
$this->artisan('automation:recover-stuck-runs')->assertExitCode(0);
$stuck->refresh();
expect($stuck->status)->toBe(RunStatus::Failed);
expect(data_get($stuck->error, 'reason'))->toBe('stuck');
expect($stuck->finished_at)->not->toBeNull();
});
it('fails runs stuck pending past the threshold', function () {
$stuck = AutomationRun::factory()->create(['status' => RunStatus::Pending, 'updated_at' => now()->subHours(3)]);
$this->artisan('automation:recover-stuck-runs')->assertExitCode(0);
$stuck->refresh();
expect($stuck->status)->toBe(RunStatus::Failed);
expect(data_get($stuck->error, 'reason'))->toBe('stuck');
expect($stuck->finished_at)->not->toBeNull();
});
it('leaves recent running runs alone', function () {
$recent = AutomationRun::factory()->create(['status' => RunStatus::Running, 'started_at' => now()->subMinute(), 'updated_at' => now()->subMinute()]);
$this->artisan('automation:recover-stuck-runs');
expect($recent->fresh()->status)->toBe(RunStatus::Running);
});
it('does not touch waiting runs', function () {
$waiting = AutomationRun::factory()->create(['status' => RunStatus::Waiting, 'updated_at' => now()->subDay(), 'next_action_at' => now()->addDay()]);
$this->artisan('automation:recover-stuck-runs');
expect($waiting->fresh()->status)->toBe(RunStatus::Waiting);
});

View file

@ -32,6 +32,15 @@
</channel></rss>
XML;
const FETCH_RSS_THREE_NEW = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>New1</title><link>https://blog.example.com/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New2</title><link>https://blog.example.com/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New3</title><link>https://blog.example.com/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
</channel></rss>
XML;
beforeEach(fn () => Bus::fake());
it('first execution dispatches nothing and stores watermark from newest item', function () {
@ -100,6 +109,121 @@
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 1);
});
it('spawns sibling runs down the item edge for each remaining new item', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'generate_1', 'type' => 'generate', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
['id' => 'e2', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'generate_1'],
],
]);
// Watermark predates all three items, so every item is "new".
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'fetch_1',
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
]);
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
// Current run handles item #1 (oldest); siblings handle #2 and #3.
expect($result->status)->toBe(NodeRunStatus::Completed);
expect($result->output['fetched']['key'])->toBe('x');
expect($result->output['fetch']['count'])->toBe(3);
expect($result->output['fetch']['spawned'])->toBe(2);
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 2);
Bus::assertDispatched(
ProcessAutomationNode::class,
fn (ProcessAutomationNode $job) => $job->nodeId === 'generate_1',
);
});
it('does not persist the production watermark on a manual real-data test', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
['id' => 'e2', 'source' => 'fetch_1', 'target' => 'end_1'],
],
]);
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'fetch_1',
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
]);
$run = AutomationRun::factory()->for($automation)->create([
'current_node_id' => 'fetch_1',
'is_manual' => true,
'is_dry_run' => false,
]);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
// Reads the production watermark, so it finds the newer items.
expect($result->status)->toBe(NodeRunStatus::Completed);
expect($result->output['fetch']['count'])->toBeGreaterThan(0);
// The persisted watermark stays put — a second identical manual test would
// surface the same items.
$state = AutomationNodeState::for($automation->id, 'fetch_1');
expect($state->data['last_item_date'])->toBe('2025-01-01T00:00:00+00:00');
});
it('advances the production watermark on a non-manual real-data run', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
['id' => 'e2', 'source' => 'fetch_1', 'target' => 'end_1'],
],
]);
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'fetch_1',
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
]);
$run = AutomationRun::factory()->for($automation)->create([
'current_node_id' => 'fetch_1',
'is_manual' => false,
'is_dry_run' => false,
]);
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
$state = AutomationNodeState::for($automation->id, 'fetch_1');
expect(CarbonImmutable::parse($state->data['last_item_date'])->toIso8601String())
->toBe(CarbonImmutable::parse('Mon, 15 Jun 2025 12:00:00 +0000')->toIso8601String());
});
it('fails when feed_url is missing', function () {
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);

View file

@ -7,10 +7,13 @@
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\Media;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\PostImagePipeline;
it('creates a draft post and writes generated output to run context', function () {
PostContentGenerator::fake([
@ -133,3 +136,168 @@
expect($format)->toBe('single');
expect($slideCount)->toBe(1);
});
it('attaches a generated image to a single-format post', function () {
PostContentGenerator::fake([
['content' => 'Single post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => 'Single post', 'image_title' => 'Title', 'image_body' => 'Body'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 1, 'path' => 'ai-images/x.webp', 'url' => 'http://x', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forSingle')->once()->andReturn([$mediaItem]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'include_image' => true,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->media)->toHaveCount(1);
});
it('attaches one image per slide for carousel', function () {
PostContentGenerator::fake([
['caption' => 'Carousel caption', 'slides' => [
['title' => 'S1', 'body' => 'B1', 'image_keywords' => ['a']],
['title' => 'S2', 'body' => 'B2', 'image_keywords' => ['b']],
['title' => 'S3', 'body' => 'B3', 'image_keywords' => ['c']],
]],
]);
PostContentHumanizer::fake([
['caption' => 'Carousel caption', 'slides' => [
['title' => 'S1', 'body' => 'B1'],
['title' => 'S2', 'body' => 'B2'],
['title' => 'S3', 'body' => 'B3'],
]],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'instagram']);
$item = fn (int $id) => ['id' => $id, 'path' => "ai-images/{$id}.webp", 'url' => "http://{$id}", 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forCarousel')->once()->andReturn([$item(1), $item(2), $item(3)]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'target_slide_count' => 3,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->media)->toHaveCount(3);
});
it('skips images when include_image is false', function () {
PostContentGenerator::fake([
['content' => 'No image post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => 'No image post', 'image_title' => 'Title', 'image_body' => 'Body'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'include_image' => false,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->media)->toHaveCount(0);
});
it('does not generate images or persist on a dry run', function () {
PostContentGenerator::fake([
['content' => 'Dry run post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => 'Dry run post', 'image_title' => 'Title', 'image_body' => 'Body'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'is_dry_run' => true,
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'include_image' => true,
]);
expect($result->status->value)->toBe('completed');
expect($result->output['generated']['dry_run'])->toBeTrue();
expect($result->output['generated']['content'])->toBe('Dry run post');
expect($result->output['generated']['image_count'])->toBe(1);
expect($result->output['generated']['post_id'])->toBeNull();
expect(Post::count())->toBe(0);
expect(Media::count())->toBe(0);
$run->refresh();
expect($run->generated_post_id)->toBeNull();
});

View file

@ -134,6 +134,38 @@
Http::assertSent(fn ($request) => $request->method() === 'POST' && $request['post_id'] === 'post-42');
});
it('sends the branded user-agent header', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/me',
'method' => 'GET',
'auth_type' => 'none',
'headers' => ['User-Agent' => 'user-supplied-agent'],
]);
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
});
it('sends custom headers configured in the editor', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/me',
'method' => 'GET',
'auth_type' => 'none',
'headers' => ['X-Custom' => 'v'],
]);
Http::assertSent(fn ($request) => $request->hasHeader('X-Custom', 'v'));
});
it('fails when url is missing', function () {
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);

View file

@ -27,6 +27,23 @@
Http::assertSent(fn ($request) => $request['title'] === 'Hello' && $request['post_url'] === 'https://t.it/p/1');
});
it('sends the branded user-agent header', function () {
Http::fake([
'hooks.example.com/*' => Http::response(['ok' => true], 200),
]);
$run = AutomationRun::factory()->create();
app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'method' => 'POST',
'headers' => ['User-Agent' => 'user-supplied-agent'],
'payload_template' => '{}',
]);
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
});
it('fails on 5xx response', function () {
Http::fake(['hooks.example.com/*' => Http::response('err', 500)]);
@ -41,6 +58,22 @@
expect($result->status)->toBe(Status::Failed);
});
it('fails on malformed payload json instead of silently sending an empty body', function () {
Http::fake(['hooks.example.com/*' => Http::response(['ok' => true], 200)]);
$run = AutomationRun::factory()->create();
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'method' => 'POST',
'payload_template' => '{ "a": }',
]);
expect($result->status)->toBe(Status::Failed);
expect($result->error['reason'])->toBe('invalid_payload_json');
Http::assertNothingSent();
});
it('treats 4xx responses as completed (only 5xx fails)', function () {
Http::fake(['hooks.example.com/*' => Http::response('not found', 404)]);

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
it('does not advance a node when the automation is paused', function () {
$automation = Automation::factory()->paused()->create([
'nodes' => [['id' => 'a', 'type' => 'end', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
'connections' => [],
]);
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending]);
(new ProcessAutomationNode($run, 'a'))->handle(app(AdvanceAutomationRun::class));
expect($run->fresh()->status)->toBe(RunStatus::Pending);
});
it('does not resume a waiting run while the automation is paused', function () {
$automation = Automation::factory()->paused()->create([
'nodes' => [
['id' => 'delay', 'type' => 'delay', 'position' => ['x' => 0, 'y' => 0], 'data' => ['duration' => 1, 'unit' => 'hours']],
['id' => 'end', 'type' => 'end', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
],
'connections' => [['id' => 'e1', 'source' => 'delay', 'target' => 'end']],
]);
$run = AutomationRun::factory()->for($automation)->waiting(now()->subMinute())->create(['current_node_id' => 'delay']);
$this->artisan('automation:process-delays');
expect($run->fresh()->status)->toBe(RunStatus::Waiting);
});
it('advances normally when the automation is active', function () {
$automation = Automation::factory()->active()->create([
'nodes' => [['id' => 'a', 'type' => 'end', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
'connections' => [],
]);
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending]);
(new ProcessAutomationNode($run, 'a'))->handle(app(AdvanceAutomationRun::class));
expect($run->fresh()->status)->toBe(RunStatus::Completed);
});
it('advances a manual test run even when the automation is not active', function () {
$automation = Automation::factory()->create([
'nodes' => [['id' => 'a', 'type' => 'end', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
'connections' => [],
]);
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending, 'is_manual' => true]);
(new ProcessAutomationNode($run, 'a'))->handle(app(AdvanceAutomationRun::class));
expect($run->fresh()->status)->toBe(RunStatus::Completed);
});
it('resumes a waiting manual test run regardless of automation status', function () {
$automation = Automation::factory()->paused()->create([
'nodes' => [
['id' => 'delay', 'type' => 'delay', 'position' => ['x' => 0, 'y' => 0], 'data' => ['duration' => 1, 'unit' => 'hours']],
['id' => 'end', 'type' => 'end', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
],
'connections' => [['id' => 'e1', 'source' => 'delay', 'target' => 'end']],
]);
$run = AutomationRun::factory()->for($automation)->waiting(now()->subMinute())->create(['current_node_id' => 'delay', 'is_manual' => true]);
$this->artisan('automation:process-delays');
expect($run->fresh()->status)->toBe(RunStatus::Completed);
});

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Models\Automation;
use App\Models\AutomationRun;
it('completes with a reason when no edge matches the handle', function () {
$automation = Automation::factory()->create([
'nodes' => [
['id' => 'a', 'type' => 'fetch_rss', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
],
'connections' => [],
]);
$run = AutomationRun::factory()->for($automation)->running('a')->create();
app(AdvanceAutomationRun::class)($run, 'a', 'no_items');
$run->refresh();
expect($run->status)->toBe(RunStatus::Completed);
expect(data_get($run->error, 'reason'))->toBe('no_matching_edge');
expect(data_get($run->error, 'handle'))->toBe('no_items');
});

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Enums\Automation\Run\Status as RunStatus;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
it('marks the run failed when the job fails', function () {
$automation = Automation::factory()->active()->create([
'nodes' => [['id' => 'a', 'type' => 'generate', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
'connections' => [],
]);
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Running, 'current_node_id' => 'a']);
(new ProcessAutomationNode($run, 'a'))->failed(new RuntimeException('boom'));
$run->refresh();
expect($run->status)->toBe(RunStatus::Failed);
expect(data_get($run->error, 'message'))->toBe('boom');
expect($run->finished_at)->not->toBeNull();
});
it('does not override an already-terminal run', function () {
$automation = Automation::factory()->active()->create(['nodes' => [], 'connections' => []]);
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Completed]);
(new ProcessAutomationNode($run, 'a'))->failed(new RuntimeException('late'));
expect($run->fresh()->status)->toBe(RunStatus::Completed);
});

View file

@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
use App\Actions\Automation\TriggerItem\EnrollTriggerItem;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Models\Automation;
use App\Models\AutomationNodeState;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
afterEach(fn () => Carbon::setTestNow());
/**
* Bind a no-op image pipeline so Generate never touches real image generation.
*/
$fakeImagePipeline = function (): void {
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forSingle')->andReturn([]);
$pipeline->shouldReceive('forCarousel')->andReturn([]);
app()->instance(PostImagePipeline::class, $pipeline);
};
$fakeContentAgents = function (string $content = 'Generated post body'): void {
PostContentGenerator::fake([
['content' => $content, 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => $content, 'image_title' => 'Title', 'image_body' => 'Body'],
]);
};
/**
* Build an RSS feed body with a single item published at the given date.
*/
$rssFeed = function (string $title, string $guid, string $pubDate): string {
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>{$title}</title><link>https://blog.example.com/{$guid}</link><guid>{$guid}</guid><pubDate>{$pubDate}</pubDate></item>
</channel></rss>
XML;
};
it('runs cron → generate → publish to completion and creates a draft post', function () use ($fakeImagePipeline, $fakeContentAgents) {
$fakeImagePipeline();
$fakeContentAgents('Post about cron trigger');
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => 'instagram_feed', 'meta' => []],
],
'prompt_template' => 'Write something',
'include_image' => false,
]],
['id' => 'p', 'type' => 'publish', 'position' => ['x' => 2, 'y' => 0], 'data' => ['mode' => 'draft']],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'g'],
['id' => 'e2', 'source' => 'g', 'target' => 'p'],
],
]);
app(EnrollTriggerItem::class)($automation, 'cron-item-1', ['topic' => 'launch']);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
expect($run->generated_post_id)->not->toBeNull();
$post = Post::find($run->generated_post_id);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Post about cron trigger');
});
it('runs fetch_rss → generate → delay → publish, pausing at the delay then resuming via the command', function () use ($fakeImagePipeline, $fakeContentAgents, $rssFeed) {
Carbon::setTestNow('2026-01-15 10:00:00');
$fakeImagePipeline();
$fakeContentAgents('Post from RSS item');
Http::fake([
'blog.example.com/*' => Http::response($rssFeed('Fresh News', 'fresh-1', 'Wed, 14 Jan 2026 12:00:00 +0000'), 200),
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 2, 'y' => 0], 'data' => [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => 'instagram_feed', 'meta' => []],
],
'prompt_template' => 'Write about {{ fetched.title }}',
'include_image' => false,
]],
['id' => 'd', 'type' => 'delay', 'position' => ['x' => 3, 'y' => 0], 'data' => ['duration' => 2, 'unit' => 'hours']],
['id' => 'p', 'type' => 'publish', 'position' => ['x' => 4, 'y' => 0], 'data' => ['mode' => 'draft']],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'f'],
['id' => 'e2', 'source' => 'f', 'source_handle' => 'default', 'target' => 'g'],
['id' => 'e3', 'source' => 'g', 'target' => 'd'],
['id' => 'e4', 'source' => 'd', 'target' => 'p'],
],
]);
// Watermark before the feed item so it counts as new.
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'f',
'data' => ['last_item_date' => '2026-01-01T00:00:00+00:00'],
]);
app(EnrollTriggerItem::class)($automation, 'rss-item-1', []);
$run = $automation->runs()->latest()->first();
// The run generated a post then hit the delay and is now waiting.
expect($run->status)->toBe(RunStatus::Waiting);
expect($run->current_node_id)->toBe('d');
expect($run->generated_post_id)->not->toBeNull();
// Move past the delay window and resume.
Carbon::setTestNow('2026-01-15 13:00:00');
$this->artisan('automation:process-delays')->assertSuccessful();
$run->refresh();
expect($run->status)->toBe(RunStatus::Completed);
$post = Post::find($run->generated_post_id);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Post from RSS item');
});
it('runs http_request → condition (true) → webhook down the yes handle', function () {
Http::fake([
'api.example.com/*' => Http::response(['status' => 'active'], 200),
'hooks.example.com/*' => Http::response(['ok' => true], 200),
]);
$workspace = Workspace::factory()->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://api.example.com/status', 'method' => 'GET']],
['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://hooks.example.com/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'h'],
['id' => 'e2', 'source' => 'h', 'target' => 'c'],
['id' => 'e3', 'source' => 'c', 'source_handle' => 'yes', 'target' => 'w'],
['id' => 'e4', 'source' => 'c', 'source_handle' => 'no', 'target' => 'e'],
],
]);
app(EnrollTriggerItem::class)($automation, 'http-true-1', []);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
Http::assertSent(fn ($request) => str_contains($request->url(), 'hooks.example.com/notify'));
});
it('runs http_request → condition (false) → end down the no handle without hitting the webhook', function () {
Http::fake([
'api.example.com/*' => Http::response(['status' => 'inactive'], 200),
'hooks.example.com/*' => Http::response(['ok' => true], 200),
]);
$workspace = Workspace::factory()->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://api.example.com/status', 'method' => 'GET']],
['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://hooks.example.com/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'h'],
['id' => 'e2', 'source' => 'h', 'target' => 'c'],
['id' => 'e3', 'source' => 'c', 'source_handle' => 'yes', 'target' => 'w'],
['id' => 'e4', 'source' => 'c', 'source_handle' => 'no', 'target' => 'e'],
],
]);
app(EnrollTriggerItem::class)($automation, 'http-false-1', []);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'hooks.example.com/notify'));
});
it('runs fetch_rss → (no_items) → end when the feed yields no new items and creates no post', function () use ($rssFeed) {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([
'blog.example.com/*' => Http::response($rssFeed('Stale', 'stale-1', 'Mon, 01 Jan 2024 12:00:00 +0000'), 200),
]);
$workspace = Workspace::factory()->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 2, 'y' => 0], 'data' => ['prompt_template' => 'x', 'include_image' => false]],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 2, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'f'],
['id' => 'e2', 'source' => 'f', 'source_handle' => 'default', 'target' => 'g'],
['id' => 'e3', 'source' => 'f', 'source_handle' => 'no_items', 'target' => 'e'],
],
]);
// Watermark newer than the feed's only item so zero items are new.
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'f',
'data' => ['last_item_date' => '2025-12-01T00:00:00+00:00'],
]);
app(EnrollTriggerItem::class)($automation, 'rss-empty-1', []);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
expect($run->generated_post_id)->toBeNull();
expect(Post::count())->toBe(0);
});

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use App\Enums\Media\Source;
use App\Enums\PostPlatform\ContentType;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\PostImagePipeline;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
$this->workspace = Workspace::factory()->create();
$this->account = SocialAccount::factory()->instagram()->create([
'workspace_id' => $this->workspace->id,
]);
Storage::put('ai-images/x.webp', 'fake-image-bytes');
$this->rendered = [
'path' => 'ai-images/x.webp',
'source_meta' => [
'keywords' => ['productivity'],
'style' => 'cinematic',
'language' => 'en',
],
];
});
test('forSingle returns a media item with the expected shape and persists a Media row', function () {
$this->mock(TemplateImageGenerator::class, function ($mock) {
$mock->shouldReceive('render')->once()->andReturn($this->rendered);
});
$structured = [
'content' => 'A single productivity tip',
'image_title' => 'Tip',
'image_body' => 'Do less',
'image_keywords' => ['productivity'],
];
$pipeline = app(PostImagePipeline::class);
$media = $pipeline->forSingle($this->workspace, $this->account, $structured, ContentType::InstagramFeed);
expect($media)->toHaveCount(1);
$item = $media[0];
expect($item)->toHaveKeys(['id', 'path', 'url', 'type', 'mime_type', 'source', 'source_meta']);
expect($item['path'])->toBe('ai-images/x.webp');
expect($item['type'])->toBe('image');
expect($item['mime_type'])->toBe('image/webp');
expect($item['source'])->toBe(Source::Ai->value);
expect($item['source_meta'])->toBe($this->rendered['source_meta']);
$this->assertDatabaseHas('medias', [
'id' => $item['id'],
'path' => 'ai-images/x.webp',
'collection' => 'ai-generated',
]);
});
test('forSingle returns an empty array when the generator renders nothing', function () {
$this->mock(TemplateImageGenerator::class, function ($mock) {
$mock->shouldReceive('render')->once()->andReturn(null);
});
$structured = [
'image_title' => 'Tip',
'image_body' => 'Do less',
'image_keywords' => [],
];
$pipeline = app(PostImagePipeline::class);
$media = $pipeline->forSingle($this->workspace, $this->account, $structured, ContentType::InstagramFeed);
expect($media)->toBe([]);
});
test('forCarousel returns one media item per slide', function () {
$this->mock(TemplateImageGenerator::class, function ($mock) {
$mock->shouldReceive('render')->times(3)->andReturn($this->rendered);
});
$structured = [
'caption' => 'Swipe',
'slides' => [
['title' => 'Tip 1', 'body' => 'First', 'image_keywords' => ['a']],
['title' => 'Tip 2', 'body' => 'Second', 'image_keywords' => ['b']],
['title' => 'Tip 3', 'body' => 'Third', 'image_keywords' => ['c']],
],
];
$pipeline = app(PostImagePipeline::class);
$media = $pipeline->forCarousel($this->workspace, $this->account, $structured, ContentType::InstagramFeed);
expect($media)->toHaveCount(3);
expect($media[0])->toHaveKeys(['id', 'path', 'url', 'type', 'mime_type', 'source', 'source_meta']);
$this->assertDatabaseCount('medias', 3);
});