|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
| 4 | +import { defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; |
| 5 | +import { FlowFunctionEntrySchema } from '@objectstack/spec/automation'; |
4 | 6 | import { lowerCallables } from './lower-callables.js'; |
5 | 7 |
|
6 | 8 | // ── #3855: `target` is the only handler slot ──────────────────────────────── |
@@ -124,3 +126,95 @@ describe('lowerCallables — declared `functions` entries (#4396)', () => { |
124 | 126 | expect(entry).toEqual({ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' }); |
125 | 127 | }); |
126 | 128 | }); |
| 129 | + |
| 130 | +// ── #4976: the lowering and the schema must round-trip ────────────────────── |
| 131 | +// |
| 132 | +// Every test above stops at the shape `lowerCallables` EMITS, and every spec |
| 133 | +// test parses only shapes an author WRITES. Nothing crossed the boundary — so |
| 134 | +// when #4396 taught this step to keep a declared entry's declaration, and the |
| 135 | +// union in `flow-function.zod.ts` was not extended in the same change, both |
| 136 | +// halves stayed green and `objectstack build` failed on the join with |
| 137 | +// `invalid_union: Invalid input` and no path past `functions`. |
| 138 | +// |
| 139 | +// These tests are that boundary, driven through the real build pipeline |
| 140 | +// (`defineStack` → `normalizeStackInput` → `lowerCallables` → parse) rather |
| 141 | +// than a hand-written sample of what the lowering is believed to emit: a |
| 142 | +// hand-written sample is a third copy of the truth and drifts exactly the way |
| 143 | +// the two halves already did. |
| 144 | +// |
| 145 | +// SCOPE: the map form. The ARRAY form (`functions: [{ name, handler }]`) does |
| 146 | +// not round-trip either — in both its bare and declared spellings, since #4343 |
| 147 | +// and #4976 each only ever touched the map — and its member lives in |
| 148 | +// `stack.zod.ts` rather than in `FlowFunctionEntrySchema`. Filed as #6238; |
| 149 | +// extend the parametrisation below when it lands. |
| 150 | +describe('lowerCallables → the spec parses what it emits (#4976)', () => { |
| 151 | + const base = { |
| 152 | + manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const }, |
| 153 | + }; |
| 154 | + |
| 155 | + /** Exactly what `objectstack compile` does, in the order it does it. */ |
| 156 | + const buildPipeline = (functions: Record<string, unknown>) => { |
| 157 | + const stack = defineStack({ ...base, functions } as never); |
| 158 | + const normalized = normalizeStackInput(stack as Record<string, unknown>); |
| 159 | + return lowerCallables(normalized); |
| 160 | + }; |
| 161 | + |
| 162 | + const cases: Array<[label: string, functions: Record<string, unknown>]> = [ |
| 163 | + ['a bare handler', { scoreLead: () => ({ score: 1 }) }], |
| 164 | + ['a declared writer', { syncBilling: { handler: () => ({ ok: true }), effect: 'writes' } }], |
| 165 | + ['a declaration that states the pure default', { scoreLead: { handler: () => ({ score: 1 }), effect: 'pure' } }], |
| 166 | + ['a declaration that states nothing', { scoreLead: { handler: () => ({ score: 1 }) } }], |
| 167 | + ['both spellings side by side', { |
| 168 | + scoreLead: () => ({ score: 1 }), |
| 169 | + syncBilling: { handler: () => ({ ok: true }), effect: 'writes' }, |
| 170 | + }], |
| 171 | + ]; |
| 172 | + |
| 173 | + for (const [label, functions] of cases) { |
| 174 | + it(`parses every entry it emits for ${label}`, () => { |
| 175 | + const emitted = (buildPipeline(functions).lowered as { |
| 176 | + functions: Record<string, unknown>; |
| 177 | + }).functions; |
| 178 | + |
| 179 | + for (const [name, entry] of Object.entries(emitted)) { |
| 180 | + const result = FlowFunctionEntrySchema.safeParse(entry); |
| 181 | + expect( |
| 182 | + result.success, |
| 183 | + `emitted entry '${name}' (${JSON.stringify(entry)}) is not a shape FlowFunctionEntrySchema accepts: ` |
| 184 | + + JSON.stringify(result.success ? [] : result.error.issues), |
| 185 | + ).toBe(true); |
| 186 | + } |
| 187 | + }); |
| 188 | + |
| 189 | + it(`parses the whole lowered stack for ${label}`, () => { |
| 190 | + // The assertion the build itself makes (`compile.ts` step 3). Parsing the |
| 191 | + // entries one by one can pass while the stack does not — `functions` is a |
| 192 | + // union of a record and an array, so a rejected entry surfaces only as |
| 193 | + // `invalid_union` on the parent, which is precisely the unreadable error |
| 194 | + // the issue is about. |
| 195 | + const { lowered } = buildPipeline(functions); |
| 196 | + const result = ObjectStackDefinitionSchema.safeParse(lowered); |
| 197 | + expect( |
| 198 | + result.success, |
| 199 | + `lowered stack rejected: ${JSON.stringify(result.success ? [] : result.error.issues)}`, |
| 200 | + ).toBe(true); |
| 201 | + }); |
| 202 | + } |
| 203 | + |
| 204 | + it('carries the declaration into the artifact, not just past the parse', () => { |
| 205 | + // Surviving the parse is worthless if `effect` is dropped on the way — that |
| 206 | + // would re-create #4396's silent un-declaring with a green build. The |
| 207 | + // artifact must still SAY 'writes', because that string is what |
| 208 | + // `mergeRuntimeModule` re-attaches the module's callable to at boot. |
| 209 | + const { lowered } = buildPipeline({ |
| 210 | + syncBilling: { handler: () => ({ ok: true }), effect: 'writes' }, |
| 211 | + }); |
| 212 | + const parsed = ObjectStackDefinitionSchema.parse(lowered) as { |
| 213 | + functions: Record<string, { handler: string; effect: string }>; |
| 214 | + }; |
| 215 | + expect(parsed.functions.syncBilling).toEqual({ handler: 'syncBilling', effect: 'writes' }); |
| 216 | + // And it is JSON — the artifact is `objectstack.json`, not a module. |
| 217 | + expect(JSON.parse(JSON.stringify(lowered)).functions.syncBilling) |
| 218 | + .toEqual({ handler: 'syncBilling', effect: 'writes' }); |
| 219 | + }); |
| 220 | +}); |
0 commit comments