From 0d4dd17a6c3d8c0e5645ff6438b3a8c89312ba45 Mon Sep 17 00:00:00 2001
From: yannickmonney <ym@tale.dev>
Date: Mon, 1 Jun 2026 11:51:11 +0200
Subject: [PATCH] fix(platform): gate automation tester on input validation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The Execute and Dry-run buttons in the automation test panel were only
disabled while a run was in flight — you could click them with invalid
JSON or with required start-node inputs left blank, and the run would
only fail downstream.

Gate both buttons on validation: the input must parse as JSON and every
required field declared in the start node's inputSchema must be
configured. A required field counts as unconfigured when it's absent,
null, a blank string, or an empty collection; 0 and false are accepted.
When blocked, an inline message explains why (invalid JSON, or the list
of required fields still to fill). Because the pre-filled template seeds
required strings as empty, a freshly-opened tester is correctly gated
until the user fills them in.

Extract getMissingRequiredFields as a pure, exported helper with unit
coverage.

Closes #1483
---
 .../components/automation-tester.tsx          | 114 +++++++++++-------
 .../utils/input-schema-template.test.ts       | 104 ++++++++++++++++
 .../utils/input-schema-template.ts            |  36 +++++-
 services/platform/messages/de.json            |   4 +
 services/platform/messages/en.json            |   4 +
 services/platform/messages/fr.json            |   4 +
 6 files changed, 223 insertions(+), 43 deletions(-)
 create mode 100644 services/platform/app/features/automations/utils/input-schema-template.test.ts

diff --git a/services/platform/app/features/automations/components/automation-tester.tsx b/services/platform/app/features/automations/components/automation-tester.tsx
index 8b2ab25437..b9d9394baf 100644
--- a/services/platform/app/features/automations/components/automation-tester.tsx
+++ b/services/platform/app/features/automations/components/automation-tester.tsx
@@ -22,7 +22,11 @@ import { cn } from '@/lib/utils/cn';
 
 import { useStartWorkflowFromFile } from '../hooks/file-mutations';
 import { useReadWorkflow } from '../hooks/file-queries';
-import { buildInputTemplateFromSchema } from '../utils/input-schema-template';
+import {
+  buildInputTemplateFromSchema,
+  getMissingRequiredFields,
+  type InputSchema,
+} from '../utils/input-schema-template';
 
 interface AutomationTesterProps {
   organizationId: string;
@@ -58,17 +62,22 @@ export function AutomationTester({
 
   const { data: workflowRead } = useReadWorkflow(organizationId, workflowSlug);
 
-  const inputTemplate = useMemo(() => {
-    if (!workflowRead?.ok) return '{}';
+  const inputSchema = useMemo<InputSchema | undefined>(() => {
+    if (!workflowRead?.ok) return undefined;
     const startStep = workflowRead.config.steps?.find(
       (s) => s.stepType === 'start',
     );
     const startConfig = startStep?.config as
-      | { inputSchema?: Parameters<typeof buildInputTemplateFromSchema>[0] }
+      | { inputSchema?: InputSchema }
       | undefined;
-    return buildInputTemplateFromSchema(startConfig?.inputSchema);
+    return startConfig?.inputSchema;
   }, [workflowRead]);
 
+  const inputTemplate = useMemo(
+    () => buildInputTemplateFromSchema(inputSchema),
+    [inputSchema],
+  );
+
   // Persist per (org, workflow) so a tester reopening the panel sees the
   // last input they ran with — typical iteration is "tweak, run, tweak, run"
   // on the same payload.
@@ -103,6 +112,16 @@ export function AutomationTester({
     }
   })();
 
+  // Gate execution: the input must be valid JSON and every required field
+  // from the start node's inputSchema must be configured. Without this, the
+  // buttons fire with missing/invalid input and the run only fails downstream.
+  const isJsonValid = parsedInput !== null;
+  const missingRequiredFields = getMissingRequiredFields(
+    inputSchema,
+    parsedInput,
+  );
+  const canRun = isJsonValid && missingRequiredFields.length === 0;
+
   // TODO: Migrate dry run to file-based workflow system
   const handleDryRun = async () => {
     if (!parsedInput) {
@@ -293,43 +312,54 @@ export function AutomationTester({
         </BorderedSection>
       </Stack>
 
-      <HStack gap={2} className="border-border border-t p-3">
-        <Button
-          variant="secondary"
-          onClick={handleDryRun}
-          disabled={isExecuting || isDryRunning}
-          className="flex-1"
-        >
-          {isDryRunning ? (
-            <>
-              <Loader2 className="mr-2 size-4 animate-spin" />
-              {t('tester.dryRunning')}
-            </>
-          ) : (
-            <>
-              <Search className="mr-2 size-4" />
-              {t('tester.dryRun.button')}
-            </>
-          )}
-        </Button>
-        <Button
-          onClick={handleExecute}
-          disabled={isExecuting || isDryRunning}
-          className="flex-1"
-        >
-          {isExecuting ? (
-            <>
-              <Loader2 className="mr-2 size-4 animate-spin" />
-              {t('tester.executing')}
-            </>
-          ) : (
-            <>
-              <Play className="mr-2 size-4" />
-              {t('tester.execute')}
-            </>
-          )}
-        </Button>
-      </HStack>
+      <Stack gap={2} className="border-border border-t p-3">
+        {!canRun && (
+          <Text variant="error-sm" role="alert">
+            {!isJsonValid
+              ? t('tester.validation.invalidJson')
+              : t('tester.validation.missingRequired', {
+                  fields: missingRequiredFields.join(', '),
+                })}
+          </Text>
+        )}
+        <HStack gap={2}>
+          <Button
+            variant="secondary"
+            onClick={handleDryRun}
+            disabled={isExecuting || isDryRunning || !canRun}
+            className="flex-1"
+          >
+            {isDryRunning ? (
+              <>
+                <Loader2 className="mr-2 size-4 animate-spin" />
+                {t('tester.dryRunning')}
+              </>
+            ) : (
+              <>
+                <Search className="mr-2 size-4" />
+                {t('tester.dryRun.button')}
+              </>
+            )}
+          </Button>
+          <Button
+            onClick={handleExecute}
+            disabled={isExecuting || isDryRunning || !canRun}
+            className="flex-1"
+          >
+            {isExecuting ? (
+              <>
+                <Loader2 className="mr-2 size-4 animate-spin" />
+                {t('tester.executing')}
+              </>
+            ) : (
+              <>
+                <Play className="mr-2 size-4" />
+                {t('tester.execute')}
+              </>
+            )}
+          </Button>
+        </HStack>
+      </Stack>
     </VStack>
   );
 }
diff --git a/services/platform/app/features/automations/utils/input-schema-template.test.ts b/services/platform/app/features/automations/utils/input-schema-template.test.ts
new file mode 100644
index 0000000000..d86bb8ef2b
--- /dev/null
+++ b/services/platform/app/features/automations/utils/input-schema-template.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  buildInputTemplateFromSchema,
+  getMissingRequiredFields,
+  type InputSchema,
+} from './input-schema-template';
+
+const schema: InputSchema = {
+  properties: {
+    sourceId: { type: 'string' },
+    limit: { type: 'number' },
+    enabled: { type: 'boolean' },
+    tags: { type: 'array' },
+    note: { type: 'string' },
+  },
+  required: ['sourceId', 'limit', 'enabled', 'tags'],
+};
+
+describe('getMissingRequiredFields', () => {
+  it('returns [] when the schema has no required fields', () => {
+    expect(getMissingRequiredFields(undefined, {})).toEqual([]);
+    expect(
+      getMissingRequiredFields({ properties: {}, required: [] }, {}),
+    ).toEqual([]);
+  });
+
+  it('treats every required field as missing when input is not an object', () => {
+    expect(getMissingRequiredFields(schema, null)).toEqual([
+      'sourceId',
+      'limit',
+      'enabled',
+      'tags',
+    ]);
+    expect(getMissingRequiredFields(schema, 'oops')).toEqual([
+      'sourceId',
+      'limit',
+      'enabled',
+      'tags',
+    ]);
+    expect(getMissingRequiredFields(schema, [1, 2])).toEqual([
+      'sourceId',
+      'limit',
+      'enabled',
+      'tags',
+    ]);
+  });
+
+  it('flags absent, null, blank-string, and empty-array required fields', () => {
+    expect(
+      getMissingRequiredFields(schema, {
+        sourceId: '',
+        limit: null,
+        // enabled absent
+        tags: [],
+      }),
+    ).toEqual(['sourceId', 'limit', 'enabled', 'tags']);
+  });
+
+  it('accepts 0 and false as configured values', () => {
+    expect(
+      getMissingRequiredFields(schema, {
+        sourceId: 'abc',
+        limit: 0,
+        enabled: false,
+        tags: ['x'],
+      }),
+    ).toEqual([]);
+  });
+
+  it('ignores whitespace-only strings', () => {
+    expect(
+      getMissingRequiredFields(schema, {
+        sourceId: '   ',
+        limit: 5,
+        enabled: true,
+        tags: ['x'],
+      }),
+    ).toEqual(['sourceId']);
+  });
+
+  it('flags an empty required object', () => {
+    const objSchema: InputSchema = {
+      properties: { payload: { type: 'object' } },
+      required: ['payload'],
+    };
+    expect(getMissingRequiredFields(objSchema, { payload: {} })).toEqual([
+      'payload',
+    ]);
+    expect(getMissingRequiredFields(objSchema, { payload: { a: 1 } })).toEqual(
+      [],
+    );
+  });
+
+  it('matches the pre-filled template (required strings start blank → flagged)', () => {
+    const template = JSON.parse(
+      buildInputTemplateFromSchema(schema),
+    ) as unknown;
+    // Required string `sourceId` defaults to "" and required array `tags`
+    // defaults to [] in the template, so a freshly-opened tester is gated.
+    expect(getMissingRequiredFields(schema, template)).toContain('sourceId');
+    expect(getMissingRequiredFields(schema, template)).toContain('tags');
+  });
+});
diff --git a/services/platform/app/features/automations/utils/input-schema-template.ts b/services/platform/app/features/automations/utils/input-schema-template.ts
index cfb03cebfb..e1ed7321ca 100644
--- a/services/platform/app/features/automations/utils/input-schema-template.ts
+++ b/services/platform/app/features/automations/utils/input-schema-template.ts
@@ -36,7 +36,7 @@ interface InputSchemaProperty {
   required?: string[];
 }
 
-interface InputSchema {
+export interface InputSchema {
   properties: Record<string, InputSchemaProperty>;
   required?: string[];
 }
@@ -87,3 +87,37 @@ export function buildInputTemplateFromSchema(
 
   return JSON.stringify(template, null, 2);
 }
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * A required value counts as "unconfigured" when it's absent, null/undefined,
+ * a blank string, or an empty collection. Numbers (`0`) and booleans (`false`)
+ * are accepted as configured — they're indistinguishable from a deliberate
+ * value, so we don't block on them.
+ */
+function isUnconfigured(value: unknown): boolean {
+  if (value === null || value === undefined) return true;
+  if (typeof value === 'string') return value.trim() === '';
+  if (Array.isArray(value)) return value.length === 0;
+  if (typeof value === 'object') return Object.keys(value).length === 0;
+  return false;
+}
+
+/**
+ * Return the names of the schema's required top-level fields that are not
+ * configured in `value`. Used to gate the test panel's Execute/Dry-run
+ * buttons so an automation can't be run with missing required inputs. When
+ * `value` isn't an object, every required field is considered missing.
+ */
+export function getMissingRequiredFields(
+  schema: InputSchema | undefined,
+  value: unknown,
+): string[] {
+  const required = schema?.required ?? [];
+  if (required.length === 0) return [];
+  if (!isRecord(value)) return [...required];
+  return required.filter((key) => isUnconfigured(value[key]));
+}
diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json
index 3b19652270..de744a0549 100644
--- a/services/platform/messages/de.json
+++ b/services/platform/messages/de.json
@@ -692,6 +692,10 @@
     "tester": {
       "inputLabel": "Testeingabe (JSON)",
       "inputDescription": "Gib Eingabedaten im JSON-Format für die Automatisierungsausführung an",
+      "validation": {
+        "invalidJson": "Gib gültiges JSON ein, um diese Automatisierung auszuführen.",
+        "missingRequired": "Konfiguriere die erforderlichen Felder, bevor du ausführst: {fields}"
+      },
       "tip": "Probelauf simuliert die Ausführung ohne Nebeneffekte. Ausführen erstellt echte Läufe, die im Tab Ausführungen sichtbar sind.",
       "execute": "Ausführen",
       "executing": "Wird ausgeführt...",
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index cea4d90ba1..c87be284a6 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -692,6 +692,10 @@
     "tester": {
       "inputLabel": "Test input (JSON)",
       "inputDescription": "Provide input data in JSON format for the automation execution",
+      "validation": {
+        "invalidJson": "Enter valid JSON to run this automation.",
+        "missingRequired": "Configure required field(s) before running: {fields}"
+      },
       "tip": "💡 Dry run simulates execution without side effects. Execute creates real runs visible in Executions tab.",
       "execute": "Execute",
       "executing": "Executing...",
diff --git a/services/platform/messages/fr.json b/services/platform/messages/fr.json
index 05f1d2b7c8..47b7dd7528 100644
--- a/services/platform/messages/fr.json
+++ b/services/platform/messages/fr.json
@@ -693,6 +693,10 @@
     "tester": {
       "inputLabel": "Entrée de test (JSON)",
       "inputDescription": "Fournis des données d'entrée au format JSON pour l'exécution de l'automatisation",
+      "validation": {
+        "invalidJson": "Saisis un JSON valide pour exécuter cette automatisation.",
+        "missingRequired": "Configure les champs requis avant d'exécuter : {fields}"
+      },
       "tip": "L'exécution à blanc simule l'exécution sans effets secondaires. L'exécution crée de véritables exécutions visibles dans l'onglet Exécutions.",
       "execute": "Exécuter",
       "executing": "Exécution...",
