feat(workflows): test-fire — safe dry-run of any flow on demand
Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
This commit is contained in:
@@ -315,4 +315,32 @@ describe('workflow engine', () => {
|
||||
const run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'testfire.event',
|
||||
nodes: [
|
||||
{ key: 't', type: 'trigger' },
|
||||
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
|
||||
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
|
||||
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
|
||||
{ key: 'end', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 't', to: 'w' },
|
||||
{ from: 'w', to: 'g' },
|
||||
{ from: 'g', handle: 'confirm', to: 'a' },
|
||||
{ from: 'g', handle: 'deny', to: 'end' },
|
||||
{ from: 'a', to: 'end' },
|
||||
],
|
||||
});
|
||||
const runId = await engine.testRun(wfId, { dryRun: true });
|
||||
const run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
|
||||
|
||||
const steps = await db('workflow_run_steps').where({ run_id: runId });
|
||||
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
|
||||
const emailStep = steps.find((s) => s.node_key === 'a');
|
||||
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,28 @@ router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, ne
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Test-fire: run the workflow on demand (default dry-run — side effects mocked,
|
||||
// waits skipped, gates auto-confirm) and return the step-by-step log.
|
||||
router.post('/:id/test-run', requirePermission('workflows.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const { entityType, entityId, payload, dryRun } = req.body || {};
|
||||
const runId = await workflows.testRun(Number(req.params.id), {
|
||||
entityType: entityType || null,
|
||||
entityId: entityId != null && entityId !== '' ? Number(entityId) : null,
|
||||
payload: payload && typeof payload === 'object' ? payload : {},
|
||||
dryRun: dryRun !== false, // default true (safe)
|
||||
});
|
||||
const run = await db('workflow_runs').where({ id: runId }).first();
|
||||
const steps = await db('workflow_run_steps').where({ run_id: runId }).orderBy('id', 'asc');
|
||||
res.json({
|
||||
runId,
|
||||
dryRun: dryRun !== false,
|
||||
status: run?.status,
|
||||
steps: steps.map((s) => ({ ...s, result: parseJson(s.result, null) })),
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// --- List / get ---
|
||||
router.get('/', requirePermission('workflows.view'), async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -48,6 +48,7 @@ registry.registerCondition('invoice_paid', async (ctx) => {
|
||||
// anything else (customer/external) respects the business-hours floor.
|
||||
registry.registerAction('send_email', async (ctx) => {
|
||||
const cfg = ctx.node.config || {};
|
||||
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_email', recipientClass: cfg.recipientClass || cfg.recipient || 'customer', emailType: cfg.emailType || cfg.template };
|
||||
const recipientClass = cfg.recipientClass || cfg.recipient || 'customer';
|
||||
const isInternal = recipientClass === 'admin' || recipientClass === 'internal';
|
||||
const to = cfg.to
|
||||
@@ -75,6 +76,7 @@ registry.registerAction('send_email', async (ctx) => {
|
||||
registry.registerAction('queue_payment_check', async (ctx) => {
|
||||
const id = ctx.run.entity_id;
|
||||
if (!id) return { skipped: true, reason: 'no invoice entity' };
|
||||
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'queue_payment_check', invoiceId: id };
|
||||
await require('../invoiceService').queuePaymentCheckEmail(id);
|
||||
return { payment_check_queued: id };
|
||||
});
|
||||
@@ -87,6 +89,7 @@ registry.registerAction('queue_payment_check', async (ctx) => {
|
||||
registry.registerAction('escalate_to_collections', async (ctx) => {
|
||||
const id = ctx.run.entity_id;
|
||||
if (!id) return { skipped: true, reason: 'no invoice entity' };
|
||||
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'escalate_to_collections', invoiceId: id };
|
||||
const { db } = ctx;
|
||||
const invoice = await db('invoices').where({ id }).first();
|
||||
if (!invoice) return { skipped: true, reason: 'invoice not found' };
|
||||
|
||||
@@ -144,6 +144,14 @@ async function advanceRun(runId) {
|
||||
break;
|
||||
}
|
||||
case 'wait': {
|
||||
// Dry-run (test-fire): don't park — pass straight through so the whole
|
||||
// flow runs in one shot, recording what it WOULD have waited for.
|
||||
if (context.vars.__dryRun) {
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) });
|
||||
break;
|
||||
}
|
||||
const wakeAt = computeWakeAt(node.config, context.vars);
|
||||
await db('workflow_runs').where({ id: runId })
|
||||
.update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) });
|
||||
@@ -151,6 +159,14 @@ async function advanceRun(runId) {
|
||||
return; // paused — scheduler resumes when wake_at passes
|
||||
}
|
||||
case 'gate': {
|
||||
// Dry-run (test-fire): auto-take the 'confirm' path so the escalation
|
||||
// is exercised end-to-end, without creating an approval / emailing.
|
||||
if (context.vars.__dryRun) {
|
||||
const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true });
|
||||
break;
|
||||
}
|
||||
await db('workflow_runs').where({ id: runId })
|
||||
.update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) });
|
||||
await recordStep(runId, node, 'waiting', { gate: true });
|
||||
@@ -364,10 +380,40 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-fire a workflow on demand (admin testing). Creates a run for the given
|
||||
* entity/payload and starts it. Defaults to dryRun: side-effecting actions are
|
||||
* mocked, waits pass through, and gates auto-take 'confirm' — so the WHOLE flow
|
||||
* runs in one shot and the step log shows exactly what it would do, without
|
||||
* sending real customer mail or charging fees.
|
||||
*/
|
||||
async function testRun(workflowId, { entityType = null, entityId = null, payload = {}, dryRun = true } = {}) {
|
||||
const wf = await db('workflows').where({ id: workflowId }).first();
|
||||
if (!wf) throw new Error('Workflow not found');
|
||||
const vars = { ...(payload || {}), __test: true };
|
||||
if (dryRun) vars.__dryRun = true;
|
||||
const dedupKey = `test:${workflowId}:${Date.now()}:${Math.round(Math.random() * 1e9)}`;
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wf.id,
|
||||
version: wf.version,
|
||||
trigger_event: `test:${wf.trigger_type}`,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
status: 'pending',
|
||||
context: JSON.stringify({ vars }),
|
||||
dedup_key: dedupKey,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
|
||||
await startRun(row.id);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
emitWorkflowEvent,
|
||||
runDueWaits,
|
||||
recoverStaleRuns,
|
||||
testRun,
|
||||
startRun,
|
||||
advanceRun,
|
||||
resumeRun,
|
||||
|
||||
@@ -212,6 +212,14 @@
|
||||
"enabled": "Aktiv",
|
||||
"disabled": "Inaktiv",
|
||||
"confirmDelete": "Diesen Workflow löschen?",
|
||||
"test": {
|
||||
"title": "Testlauf",
|
||||
"hint": "Probelauf: durchläuft den ganzen Ablauf sofort (Wartezeiten übersprungen, Gates automatisch bestätigt), Nebeneffekte werden nur simuliert – keine echten E-Mails. Optional eine Entitäts-ID (z. B. eine Rechnung) angeben, damit Bedingungen sie lesen können.",
|
||||
"entityId": "Entitäts-ID (optional, z. B. Rechnungs-ID)",
|
||||
"run": "Probelauf starten",
|
||||
"result": "Ergebnis",
|
||||
"failed": "Testlauf fehlgeschlagen"
|
||||
},
|
||||
"toast": {
|
||||
"createFailed": "Workflow konnte nicht erstellt werden",
|
||||
"deleted": "Workflow gelöscht",
|
||||
|
||||
@@ -212,6 +212,14 @@
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"confirmDelete": "Delete this workflow?",
|
||||
"test": {
|
||||
"title": "Test run",
|
||||
"hint": "Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.",
|
||||
"entityId": "Entity id (optional, e.g. invoice id)",
|
||||
"run": "Run dry test",
|
||||
"result": "Result",
|
||||
"failed": "Test run failed"
|
||||
},
|
||||
"toast": {
|
||||
"createFailed": "Could not create workflow",
|
||||
"deleted": "Workflow deleted",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* workflow" mints a minimal trigger→action graph and opens the editor. A
|
||||
* pending-approvals shortcut sits in the header.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil } from 'lucide-react';
|
||||
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload } from '../../../services/workflows.service';
|
||||
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service';
|
||||
|
||||
const NEW_WORKFLOW: WorkflowSavePayload = {
|
||||
name: 'New workflow',
|
||||
@@ -43,6 +43,18 @@ export const WorkflowsListPage: React.FC = () => {
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.createFailed', 'Could not create workflow') as string)),
|
||||
});
|
||||
|
||||
const [testTarget, setTestTarget] = useState<WorkflowSummary | null>(null);
|
||||
const [testEntityId, setTestEntityId] = useState('');
|
||||
const [testResult, setTestResult] = useState<WorkflowTestResult | null>(null);
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => workflowsService.testRun(testTarget!.id, {
|
||||
entityId: testEntityId ? Number(testEntityId) : null,
|
||||
dryRun: true,
|
||||
}),
|
||||
onSuccess: (res) => setTestResult(res),
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }),
|
||||
@@ -112,6 +124,9 @@ export const WorkflowsListPage: React.FC = () => {
|
||||
>
|
||||
{isEnabled(w) ? t('workflows.enabled', 'Enabled') : t('workflows.disabled', 'Disabled')}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setTestResult(null); setTestEntityId(''); setTestTarget(w); }} aria-label={t('workflows.test.title', 'Test run') as string}>
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/admin/workflows/${w.id}`)} aria-label={t('common.edit', 'Edit') as string}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -130,6 +145,46 @@ export const WorkflowsListPage: React.FC = () => {
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{testTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setTestTarget(null)}>
|
||||
<div className="w-full max-w-lg rounded-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 space-y-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.test.title', 'Test run')} — {testTarget.name}</h2>
|
||||
<button type="button" onClick={() => setTestTarget(null)} aria-label={t('common.close', 'Close') as string} className="text-neutral-500 dark:text-neutral-400">✕</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.test.hint', 'Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.')}
|
||||
</p>
|
||||
<input
|
||||
value={testEntityId} onChange={(e) => setTestEntityId(e.target.value)}
|
||||
placeholder={t('workflows.test.entityId', 'Entity id (optional, e.g. invoice id)') as string}
|
||||
className="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
/>
|
||||
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
|
||||
{t('workflows.test.run', 'Run dry test')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="mt-2">
|
||||
<div className="text-sm mb-1 text-neutral-700 dark:text-neutral-300">
|
||||
{t('workflows.test.result', 'Result')}: <span className="font-medium">{testResult.status}</span>
|
||||
</div>
|
||||
<ol className="text-xs space-y-1 max-h-72 overflow-y-auto">
|
||||
{testResult.steps.map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2 border-b border-neutral-100 dark:border-neutral-800 pb-1">
|
||||
<span className="text-neutral-400 w-6 shrink-0">{i + 1}.</span>
|
||||
<span className="font-mono text-neutral-700 dark:text-neutral-300">{s.node_type}:{s.node_key}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{s.status}</span>
|
||||
{s.result && (s.result as any).would ? <span className="text-purple-600 dark:text-purple-400">→ would {String((s.result as any).would)}</span> : null}
|
||||
{s.error ? <span className="text-red-600 dark:text-red-400">{s.error}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -89,4 +89,23 @@ export const workflowsService = {
|
||||
approvals: async (): Promise<WorkflowApproval[]> => (await api.get<WorkflowApproval[]>('/admin/workflows/approvals')).data,
|
||||
actApproval: async (id: number, action: 'confirm' | 'deny') =>
|
||||
(await api.post(`/admin/workflows/approvals/${id}/${action}`)).data,
|
||||
testRun: async (
|
||||
id: number,
|
||||
body: { entityType?: string | null; entityId?: number | null; payload?: Record<string, unknown>; dryRun?: boolean },
|
||||
): Promise<WorkflowTestResult> => (await api.post<WorkflowTestResult>(`/admin/workflows/${id}/test-run`, body)).data,
|
||||
};
|
||||
|
||||
export interface WorkflowTestStep {
|
||||
node_key: string;
|
||||
node_type?: string | null;
|
||||
status: string;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowTestResult {
|
||||
runId: number;
|
||||
dryRun: boolean;
|
||||
status: string;
|
||||
steps: WorkflowTestStep[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user