Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ interface InputSchemaProperty {
required?: string[];
}

interface InputSchema {
export interface InputSchema {
properties: Record<string, InputSchemaProperty>;
required?: string[];
}
Expand Down Expand Up @@ -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]));
}
4 changes: 4 additions & 0 deletions services/platform/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
4 changes: 4 additions & 0 deletions services/platform/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
4 changes: 4 additions & 0 deletions services/platform/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down