|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { AutomationEngine } from './engine.js'; |
| 5 | + |
| 6 | +/** |
| 7 | + * #9889 — node input-schema validation must hold on EVERY attempt, not only |
| 8 | + * the first. |
| 9 | + * |
| 10 | + * `validateNodeInputSchemas` reports by throwing, and the retry handoff lives |
| 11 | + * inside `execute()`'s catch. Before the repair, only `execute()` called the |
| 12 | + * guard: for a flow whose node config violates its own declared `inputSchema` |
| 13 | + * under `errorHandling.strategy: 'retry'`, attempt 1 threw in the guard before |
| 14 | + * any node executed, the catch routed to `retryExecution`, and attempts 2..N |
| 15 | + * ran through `executeWithoutRetry` — which never called the guard — so the |
| 16 | + * nodes attempt 1 refused permission to run were executed for real, with the |
| 17 | + * config the guard rejected. A `retry` strategy was a way past authoring-time |
| 18 | + * validation. |
| 19 | + * |
| 20 | + * The pins here are written against the OBSERVABLE side effect (an executor |
| 21 | + * spy counting real node executions), not only the thrown error: the defect's |
| 22 | + * whole harm is a side-effecting node (a data write, an HTTP call, an email) |
| 23 | + * running with rejected config, and an assertion on the returned error alone |
| 24 | + * stays green while that node runs. |
| 25 | + * |
| 26 | + * On the envelope: the refusal is asserted as `success: false` + |
| 27 | + * `status: 'failed'` + the guard's own message. There is no ADR-0112 `code` |
| 28 | + * to assert — deliberately: #9378's classification gives `code` to the |
| 29 | + * NEVER-DISPATCHED exits (`FLOW_DISABLED`, `FLOW_NO_START_NODE`) and `status: |
| 30 | + * 'failed'` to the dispatched-and-failed exits, and the guard's throw rides |
| 31 | + * the latter family on both attempt paths. Whether a definition-level refusal |
| 32 | + * should instead be classified non-retryable (its verdict cannot change per |
| 33 | + * attempt) is the open question #9889 leaves to a maintainer ruling; these |
| 34 | + * pins assert the parity floor only. |
| 35 | + */ |
| 36 | + |
| 37 | +function createTestLogger(): any { |
| 38 | + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => createTestLogger() }; |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * An engine holding one flow whose single work node counts every REAL |
| 43 | + * execution — the observable the negative pins are written against. |
| 44 | + */ |
| 45 | +function countingFlowEngine(opts: { |
| 46 | + config: Record<string, unknown>; |
| 47 | + inputSchema: Record<string, { type: string; required?: boolean }>; |
| 48 | + /** What the spy executor returns; defaults to success. */ |
| 49 | + executeResult?: (attempt: number) => { success: boolean; error?: string }; |
| 50 | +}) { |
| 51 | + const engine = new AutomationEngine(createTestLogger()); |
| 52 | + const runs = { count: 0 }; |
| 53 | + |
| 54 | + engine.registerNodeExecutor({ |
| 55 | + type: 'script', |
| 56 | + async execute() { |
| 57 | + runs.count++; |
| 58 | + return opts.executeResult ? opts.executeResult(runs.count) : { success: true }; |
| 59 | + }, |
| 60 | + } as any); |
| 61 | + |
| 62 | + engine.registerFlow('guarded', { |
| 63 | + name: 'guarded', |
| 64 | + label: 'Guarded', |
| 65 | + type: 'autolaunched', |
| 66 | + errorHandling: { strategy: 'retry', maxRetries: 2, backoffMs: 0 }, |
| 67 | + nodes: [ |
| 68 | + { id: 'start', type: 'start', label: 'Start' }, |
| 69 | + { |
| 70 | + id: 'work', |
| 71 | + type: 'script' as any, |
| 72 | + label: 'Work', |
| 73 | + config: opts.config, |
| 74 | + inputSchema: opts.inputSchema, |
| 75 | + }, |
| 76 | + { id: 'end', type: 'end', label: 'End' }, |
| 77 | + ], |
| 78 | + edges: [ |
| 79 | + { id: 'e0', source: 'start', target: 'work' }, |
| 80 | + { id: 'e1', source: 'work', target: 'end' }, |
| 81 | + ], |
| 82 | + } as any); |
| 83 | + |
| 84 | + return { engine, runs }; |
| 85 | +} |
| 86 | + |
| 87 | +describe("#9889 — input-schema refusal holds on every attempt under strategy: 'retry'", () => { |
| 88 | + it('never executes a node whose config mis-types its declared inputSchema — on ANY attempt', async () => { |
| 89 | + const { engine, runs } = countingFlowEngine({ |
| 90 | + config: { count: 'not_a_number' }, |
| 91 | + inputSchema: { count: { type: 'number', required: true } }, |
| 92 | + }); |
| 93 | + |
| 94 | + const result = await engine.execute('guarded'); |
| 95 | + |
| 96 | + // The refusal, as the caller sees it (see header for why no `code`). |
| 97 | + expect(result.success).toBe(false); |
| 98 | + expect(result.status).toBe('failed'); |
| 99 | + expect(result.error).toContain("expected type 'number' but got 'string'"); |
| 100 | + |
| 101 | + // The point of the card: the side-effecting node ran ZERO times. |
| 102 | + // Pre-repair this was 2 — refused on attempt 1, executed for real on |
| 103 | + // attempts 2 and 3. |
| 104 | + expect(runs.count).toBe(0); |
| 105 | + |
| 106 | + // And the refusal happened PER ATTEMPT, not by short-circuiting the |
| 107 | + // retry loop: every attempt still dispatched and consumed budget |
| 108 | + // (retry accounting unchanged — the non-retryable classification is |
| 109 | + // the open question, not this repair), so the run log holds one |
| 110 | + // failed row per attempt (1 initial + maxRetries), each carrying the |
| 111 | + // guard's own message. |
| 112 | + const attemptRows = await engine.listRuns('guarded', { status: 'failed' }); |
| 113 | + expect(attemptRows).toHaveLength(3); |
| 114 | + for (const row of attemptRows) { |
| 115 | + expect(row.error).toContain("expected type 'number' but got 'string'"); |
| 116 | + } |
| 117 | + }); |
| 118 | + |
| 119 | + it('never executes a node missing a required declared input — on ANY attempt', async () => { |
| 120 | + const { engine, runs } = countingFlowEngine({ |
| 121 | + config: {}, |
| 122 | + inputSchema: { url: { type: 'string', required: true } }, |
| 123 | + }); |
| 124 | + |
| 125 | + const result = await engine.execute('guarded'); |
| 126 | + |
| 127 | + expect(result.success).toBe(false); |
| 128 | + expect(result.status).toBe('failed'); |
| 129 | + expect(result.error).toContain("missing required input parameter 'url'"); |
| 130 | + expect(runs.count).toBe(0); |
| 131 | + }); |
| 132 | + |
| 133 | + it('still retries a VALID flow normally — the guard refuses nothing attempt 1 allowed', async () => { |
| 134 | + const { engine, runs } = countingFlowEngine({ |
| 135 | + config: { count: 42 }, |
| 136 | + inputSchema: { count: { type: 'number', required: true } }, |
| 137 | + // Attempt 1 fails downstream (a transient error, the case retry |
| 138 | + // exists for); attempt 2 succeeds. |
| 139 | + executeResult: attempt => |
| 140 | + attempt === 1 ? { success: false, error: 'downstream 503' } : { success: true }, |
| 141 | + }); |
| 142 | + |
| 143 | + const result = await engine.execute('guarded'); |
| 144 | + |
| 145 | + expect(result.success).toBe(true); |
| 146 | + // Attempt 1 ran and failed, attempt 2 ran and succeeded — the fix must |
| 147 | + // not turn a legitimate retry into a refusal. |
| 148 | + expect(runs.count).toBe(2); |
| 149 | + }); |
| 150 | +}); |
0 commit comments