|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 4 | +import { AutomationEngine, type FlowFunctionHandler } from '../engine.js'; |
| 5 | +import { registerScreenNodes } from './screen-nodes.js'; |
| 6 | + |
| 7 | +function createTestLogger() { |
| 8 | + return { |
| 9 | + info: () => {}, |
| 10 | + warn: () => {}, |
| 11 | + error: () => {}, |
| 12 | + debug: () => {}, |
| 13 | + child: () => createTestLogger(), |
| 14 | + } as any; |
| 15 | +} |
| 16 | + |
| 17 | +function createCtx() { |
| 18 | + return { logger: createTestLogger(), getService: () => undefined } as any; |
| 19 | +} |
| 20 | + |
| 21 | +/** A one-`script`-node flow whose script node carries `config`. */ |
| 22 | +function scriptFlow(config: Record<string, unknown>) { |
| 23 | + return { |
| 24 | + name: 'script_flow', |
| 25 | + label: 'Script Flow', |
| 26 | + type: 'autolaunched' as const, |
| 27 | + nodes: [ |
| 28 | + { id: 'start', type: 'start' as const, label: 'Start' }, |
| 29 | + { id: 'run', type: 'script' as const, label: 'Run', config }, |
| 30 | + { id: 'end', type: 'end' as const, label: 'End' }, |
| 31 | + ], |
| 32 | + edges: [ |
| 33 | + { id: 'e1', source: 'start', target: 'run' }, |
| 34 | + { id: 'e2', source: 'run', target: 'end' }, |
| 35 | + ], |
| 36 | + }; |
| 37 | +} |
| 38 | + |
| 39 | +describe('script node (#1870 — callable resolution)', () => { |
| 40 | + let engine: AutomationEngine; |
| 41 | + |
| 42 | + beforeEach(() => { |
| 43 | + engine = new AutomationEngine(createTestLogger()); |
| 44 | + registerScreenNodes(engine, createCtx()); |
| 45 | + }); |
| 46 | + |
| 47 | + it('runs the built-in email side-effect', async () => { |
| 48 | + engine.registerFlow('script_flow', scriptFlow({ actionType: 'email', template: 't', recipients: ['a'] })); |
| 49 | + const result = await engine.execute('script_flow', {} as any); |
| 50 | + expect(result.success).toBe(true); |
| 51 | + }); |
| 52 | + |
| 53 | + it('invokes a registered function and captures its return value as output', async () => { |
| 54 | + const calls: Array<Record<string, unknown>> = []; |
| 55 | + const fn: FlowFunctionHandler = (c) => { |
| 56 | + calls.push(c.input); |
| 57 | + return { triaged: true, priority: 'high' }; |
| 58 | + }; |
| 59 | + engine.setFunctionResolver((name) => (name === 'helpdesk.aiTriageStub' ? fn : undefined)); |
| 60 | + |
| 61 | + engine.registerFlow('script_flow', scriptFlow({ |
| 62 | + function: 'helpdesk.aiTriageStub', |
| 63 | + inputs: { ticket: 't_1' }, |
| 64 | + })); |
| 65 | + const result = await engine.execute('script_flow', {} as any); |
| 66 | + |
| 67 | + expect(result.success).toBe(true); |
| 68 | + expect(calls).toEqual([{ ticket: 't_1' }]); |
| 69 | + }); |
| 70 | + |
| 71 | + it('resolves a bare actionType that matches no built-in as a function name', async () => { |
| 72 | + let called = false; |
| 73 | + engine.setFunctionResolver((name) => (name === 'pm.aiRiskAssessmentStub' ? (() => { called = true; return 1; }) : undefined)); |
| 74 | + engine.registerFlow('script_flow', scriptFlow({ actionType: 'pm.aiRiskAssessmentStub' })); |
| 75 | + const result = await engine.execute('script_flow', {} as any); |
| 76 | + expect(result.success).toBe(true); |
| 77 | + expect(called).toBe(true); |
| 78 | + }); |
| 79 | + |
| 80 | + it('FAILS LOUDLY for an unregistered function instead of silently no-op (#1870)', async () => { |
| 81 | + // No resolver wired → nothing resolves. |
| 82 | + engine.registerFlow('script_flow', scriptFlow({ function: 'helpdesk.aiTriageStub' })); |
| 83 | + const result = await engine.execute('script_flow', {} as any); |
| 84 | + expect(result.success).toBe(false); |
| 85 | + expect(result.error).toMatch(/aiTriageStub/); |
| 86 | + expect(result.error).toMatch(/no function named|not a built-in/i); |
| 87 | + }); |
| 88 | + |
| 89 | + it('FAILS LOUDLY when the script node declares no target at all (actionType: undefined repro)', async () => { |
| 90 | + engine.registerFlow('script_flow', scriptFlow({ actionType: undefined })); |
| 91 | + const result = await engine.execute('script_flow', {} as any); |
| 92 | + expect(result.success).toBe(false); |
| 93 | + expect(result.error).toMatch(/neither .*actionType.* nor .*function|nothing to run/i); |
| 94 | + }); |
| 95 | + |
| 96 | + it('surfaces a thrown function as a loud step failure', async () => { |
| 97 | + engine.setFunctionResolver(() => () => { throw new Error('boom'); }); |
| 98 | + engine.registerFlow('script_flow', scriptFlow({ function: 'explode' })); |
| 99 | + const result = await engine.execute('script_flow', {} as any); |
| 100 | + expect(result.success).toBe(false); |
| 101 | + expect(result.error).toMatch(/explode.*failed|failed.*boom|boom/i); |
| 102 | + }); |
| 103 | +}); |
0 commit comments