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(() => { + if (!workflowRead?.ok) return undefined; const startStep = workflowRead.config.steps?.find( (s) => s.stepType === 'start', ); const startConfig = startStep?.config as - | { inputSchema?: Parameters[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({ - - - - + + {!canRun && ( + + {!isJsonValid + ? t('tester.validation.invalidJson') + : t('tester.validation.missingRequired', { + fields: missingRequiredFields.join(', '), + })} + + )} + + + + + ); } 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; required?: string[]; } @@ -87,3 +87,37 @@ export function buildInputTemplateFromSchema( return JSON.stringify(template, null, 2); } + +function isRecord(value: unknown): value is Record { + 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...",