diff --git a/sdk/tests/authored-flow.test.ts b/sdk/tests/authored-flow.test.ts index 8be463a8f..b2bcc9edd 100644 --- a/sdk/tests/authored-flow.test.ts +++ b/sdk/tests/authored-flow.test.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs'; import type { Server } from 'node:net'; -import { flow, type FlowHeader } from '@relayflows/surface'; +import { flow, type Ctx, type FlowHeader } from '@relayflows/surface'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; import { JournalClient } from '../src/journal-client.js'; @@ -58,9 +58,7 @@ describe('authored flow journal executor', () => { disposition: 'step_done', output: failed ? null : { exit_code: 0, - stdout_tail: step.command === 'printf authored-journal-ok' - ? 'authored-journal-ok' - : '', + stdout_tail: outputFor(step.command), stderr_tail: '', }, }, @@ -386,4 +384,143 @@ describe('authored flow journal executor', () => { client.close(); } }); + it('passes direct input into the journal-backed authored body', async () => { + const handle = flow<{ value: string }>('input-backed', async (f, input) => { + await f.run(`emit:${input.value}`); + f.done('success'); + }); + const client = await connectedClient('authored-flow-input-test'); + const before = startedSpecs.length; + + try { + await executeAuthoredFlow(handle, client, { value: 'from-direct-input' }); + expect(commandsSince(before)).toEqual(['emit:from-direct-input', ':']); + } finally { + client.close(); + } + }); + + it.each([ + ['truthiness', async (f: Ctx, value: string) => { + if (value) await f.run('branch:truthy'); + }, 'value', 'branch:truthy'], + ['negation', async (f: Ctx, value: string) => { + if (!value) await f.run('branch:negated'); + }, '', 'branch:negated'], + ['loose equality', async (f: Ctx, value: string) => { + if (value == 'value') await f.run('branch:loose-equal'); + }, 'value', 'branch:loose-equal'], + ['strict equality', async (f: Ctx, value: string) => { + if (value === 'value') await f.run('branch:strict-equal'); + }, 'value', 'branch:strict-equal'], + ['ternary', async (f: Ctx, value: string) => { + await f.run(value ? 'branch:ternary-true' : 'branch:ternary-false'); + }, '', 'branch:ternary-false'], + ['logical and', async (f: Ctx, value: string) => { + value && await f.run('branch:logical-and'); + }, 'value', 'branch:logical-and'], + ['logical or', async (f: Ctx, value: string) => { + value || await f.run('branch:logical-or'); + }, '', 'branch:logical-or'], + ])('evaluates journal output through JavaScript %s', async (_label, branch, emitted, expected) => { + const handle = flow(`operator-${_label}`, async (f) => { + const value = await f.run(`emit:${emitted}`); + await branch(f, value); + f.done('success'); + }); + const client = await connectedClient(`authored-flow-${_label}-test`); + const before = startedSpecs.length; + + try { + await executeAuthoredFlow(handle, client); + expect(commandsSince(before)).toEqual([`emit:${emitted}`, expected, ':']); + } finally { + client.close(); + } + }); + + it('preserves separately awaited sibling ordering before the join', async () => { + const handle = flow('separate-awaits', async (f) => { + const left = f.run('emit:left'); + const right = f.run('emit:right'); + await left; + await right; + await f.run('joined'); + f.done('success'); + }); + const client = await connectedClient('authored-flow-separate-awaits-test'); + const before = startedSpecs.length; + + try { + await executeAuthoredFlow(handle, client); + expect(commandsSince(before)).toEqual(['emit:left', 'emit:right', 'joined', ':']); + } finally { + client.close(); + } + }); + + it('requires an explicit completion after journal-backed steps', async () => { + const handle = flow('missing-completion', async (f) => { + await f.run('emit:ran'); + }); + const client = await connectedClient('authored-flow-missing-completion-test'); + try { + // The PROPERTY is what this pins: a body that runs a journal-backed step + // and never calls done() must be refused, not silently completed. + // + // The original version of this test (dropped in #140, restored here) + // asserted `missing_completion`. On current main the refusal arrives as + // `unawaited_step` instead, because `verifyAuthoredOperations` runs + // before the `requestedCompletion === undefined` check in + // `authored-flow-executor.ts` and throws first. The body here DOES await + // its step, so "returned with unawaited steps" is a misleading label for + // it -- see #183. Asserting either code specifically would either fail on + // main or bake in a message that looks wrong, so this asserts the + // refusal and its class instead. + await expect(executeAuthoredFlow(handle, client)).rejects.toMatchObject({ + name: 'AuthoredFlowExecutionError', + }); + } finally { + client.close(); + } + }); + + /// Reaches `missing_completion` itself, which the case above cannot. + /// + /// With a journal-backed step in the body, `verifyAuthoredOperations` refuses + /// first and `missing_completion` is never evaluated -- verified by disabling + /// that check, which left every other test in this file green. A body with no + /// operations to verify is what actually exercises it. + it('refuses a body that completes nothing at all', async () => { + const handle = flow('no-completion-no-steps', async () => { + // no f.run, no f.done + }); + const client = await connectedClient('authored-flow-no-completion-test'); + try { + await expect(executeAuthoredFlow(handle, client)).rejects.toMatchObject({ + code: 'missing_completion', + }); + } finally { + client.close(); + } + }); + + async function connectedClient(name: string): Promise { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello(name); + return client; + } + + function commandsSince(index: number): string[] { + return startedSpecs.slice(index).map((spec) => { + const steps = spec['steps'] as Record[]; + return steps[0]!['command'] as string; + }); + } }); + +function outputFor(command: string): string { + if (command.startsWith('emit:')) return command.slice('emit:'.length); + return command === 'printf authored-journal-ok' ? 'authored-journal-ok' : ''; +}