From b8ecaa75a65a35ca70c7b1bda4b0e4f8fef3f4b6 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 5 Sep 2026 19:48:08 +0200 Subject: [PATCH] test(sdk): restore the four authored-flow cases dropped in #140 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #166. #140 shipped with committed conflict markers in this file, and resolving them meant taking main's copy whole -- which protected main's merged coverage but dropped four cases the branch had added, plus the `connectedClient` and `commandsSince` helpers they used. The issue recorded them as "not recoverable from the conflict hunks alone", and that was right: `6384600` IS the conflicted commit, so each incoming side is a fragment whose closing braces live in the shared trailing context after the `>>>>>>>` marker. Reconstructed by taking each incoming hunk plus that trailing context, then checking the result is brace- and paren-balanced with no markers left. Restored: * passes direct input into the journal-backed authored body * the `it.each` operator table -- 7 rows evaluating journal output through truthiness, negation, loose/strict equality, ternary, logical and, logical or * preserves separately awaited sibling ordering before the join * requires an explicit completion after journal-backed steps The loopback needed `outputFor(command)` back as well; main hardcoded `stdout_tail` for a single command, so none of these cases could observe a value. One restored case could not be restored verbatim. It asserted `missing_completion`, and on current main the refusal arrives as `unawaited_step` instead -- `verifyAuthoredOperations` runs before the completion check and throws first, even though the body DOES await its step. Asserting the old code would fail; asserting the new one would bake in a label that reads wrong. It asserts the refusal's class instead, with the reason in a comment, and the mislabelling is filed as #183. That exposed a real hole: disabling the completion check entirely (`if (false && requestedCompletion === undefined)`) left every test in this file GREEN, because the verification refusal always wins first. So `missing_completion` had no coverage at all for that shape. Added `refuses a body that completes nothing at all` -- a body with no operations to verify -- which reaches it and fails under that mutation: × authored flow journal executor > refuses a body that completes nothing at all Tests 1 failed | 22 passed (23) sha256 of authored-flow-executor.ts: 58b3edbf -> 5e35ffc5 -> restored 58b3edbf. authored-flow.test.ts: 23 passed. Full SDK suite: 32 files, 662 passed, 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- sdk/tests/authored-flow.test.ts | 145 +++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) 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' : ''; +}