|
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | 4 | import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema, textSlotTemplateRefusal } from '@objectstack/spec/automation'; |
| 5 | +// [#19939] The value-slot judge, asked by the `flow-bare-dollar-reference` pins |
| 6 | +// below for the spelling its hint must carry and for the verdict on it. |
| 7 | +import { flowNodeValueTemplateRefusals, VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation'; |
5 | 8 | import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; |
6 | 9 | // [#5659] The shared identity reduction, asserted beside the rule that consumes |
7 | 10 | // it — the rule's verdict and the drivers' verdict are one object now. |
@@ -2561,6 +2564,141 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule |
2561 | 2564 | expect(fnds[0].hint).toContain(textSlotTemplateRefusal('{{ $User.Id }}')!); |
2562 | 2565 | expect(fnds[0].hint).not.toContain('{{ $User.Id }}'); |
2563 | 2566 | }); |
| 2567 | + // [#19939] What the hint prescribes in a text slot, put back in the |
| 2568 | + // slot, is refused by no judge the slot has: the hole for an |
| 2569 | + // engine-bound reference, and for the run user the judge's own remedy — |
| 2570 | + // its id computed by an `assignment` node's CEL envelope, written as a hole. |
| 2571 | + it('each spelling it prescribes passes the text-slot judge and the build door with 0 refusals', () => { |
| 2572 | + const [errorRef] = notifyText('Failed: $error.message'); |
| 2573 | + const hole = /`(\{\{ [^`]* \}\})`/.exec(errorRef!.hint!)?.[1]; |
| 2574 | + expect(hole).toBe('{{ $error.message }}'); |
| 2575 | + const [userRef] = notifyText('Closed by $User.Id'); |
| 2576 | + const computed = /assignments: \{ v: \{ dialect: 'cel', source: '([^']*)' \} \}/.exec(userRef!.hint!)?.[1]; |
| 2577 | + expect(computed).toBe('current_user.id'); |
| 2578 | + const title = `Failed: ${hole}, closed by {{ v }}`; |
| 2579 | + expect(textSlotTemplateRefusal(title)).toBeUndefined(); |
| 2580 | + const stack = { |
| 2581 | + flows: [{ |
| 2582 | + name: 'fault_notice', label: 'Fault notice', type: 'autolaunched', |
| 2583 | + nodes: [ |
| 2584 | + { id: 'start', type: 'start', label: 'Start' }, |
| 2585 | + { id: 'who', type: 'assignment', label: 'Who', config: { assignments: { v: CEL(computed!) } } }, |
| 2586 | + { id: 'tell', type: 'notify', label: 'Tell', config: { recipients: ['u1'], title } }, |
| 2587 | + { id: 'done', type: 'end', label: 'Done' }, |
| 2588 | + ], |
| 2589 | + edges: [ |
| 2590 | + { id: 'e1', source: 'start', target: 'who' }, |
| 2591 | + { id: 'e2', source: 'who', target: 'tell' }, |
| 2592 | + { id: 'e3', source: 'tell', target: 'done' }, |
| 2593 | + ], |
| 2594 | + }], |
| 2595 | + }; |
| 2596 | + expect(validateStackExpressions(stack).filter((i) => i.severity === 'error')).toEqual([]); |
| 2597 | + expect(lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP)).toEqual([]); |
| 2598 | + }); |
| 2599 | + }); |
| 2600 | + |
| 2601 | + // [#19939] A value slot (`create_record` / `update_record` `fields.*`, |
| 2602 | + // `assignment` values) reads the CEL value envelope, and refuses every |
| 2603 | + // `{…}` token but the date macros — `{$User.*}` since the second pass. So |
| 2604 | + // the hint there names the envelope the spec's value-slot judge writes for |
| 2605 | + // the token the reference names, asked of the judge and never re-spelled, |
| 2606 | + // and each envelope it prescribes, put back in the slot, passes every |
| 2607 | + // judge the slot has: the value-slot judge, the build door, the flow |
| 2608 | + // contract and this rule. |
| 2609 | + describe('in a value slot', () => { |
| 2610 | + /** A start → write → end flow, the whole shape the build door judges. */ |
| 2611 | + function valueFlow(type: string, config: Record<string, unknown>) { |
| 2612 | + return { |
| 2613 | + flows: [{ |
| 2614 | + name: 'stamp_owner', label: 'Stamp owner', type: 'autolaunched', |
| 2615 | + nodes: [ |
| 2616 | + { id: 'start', type: 'start', label: 'Start' }, |
| 2617 | + { id: 'write', type, label: 'Write', config }, |
| 2618 | + { id: 'done', type: 'end', label: 'Done' }, |
| 2619 | + ], |
| 2620 | + edges: [{ id: 'e1', source: 'start', target: 'write' }, { id: 'e2', source: 'write', target: 'done' }], |
| 2621 | + }], |
| 2622 | + }; |
| 2623 | + } |
| 2624 | + const bareDollar = (stack: ReturnType<typeof valueFlow>) => |
| 2625 | + lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF); |
| 2626 | + /** The value-slot judge's own CEL spelling of a `{…}` token — what the hint carries word for word. */ |
| 2627 | + const judgeSpelling = (token: string) => |
| 2628 | + valueSlotTemplateRefusals(token)[0]!.message.slice(VALUE_SLOT_TEMPLATE_REFUSAL.length).trim(); |
| 2629 | + /** Every concrete envelope source a hint prescribes; the `'…'` of its lead sentence is a placeholder. */ |
| 2630 | + function prescribedSources(hint: string): string[] { |
| 2631 | + const out: string[] = []; |
| 2632 | + for (const m of hint.matchAll(/\{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/g)) { |
| 2633 | + const source = m[1] ?? (JSON.parse(m[2]!) as string); |
| 2634 | + if (source !== '…') out.push(source); |
| 2635 | + } |
| 2636 | + return out; |
| 2637 | + } |
| 2638 | + /** The refusals of the slot's judges on `stack`'s write node. */ |
| 2639 | + function refusalsOf(stack: ReturnType<typeof valueFlow>) { |
| 2640 | + const node = stack.flows[0]!.nodes[1]!; |
| 2641 | + const parsed = FlowSchema.safeParse(stack.flows[0]); |
| 2642 | + return { |
| 2643 | + valueJudge: flowNodeValueTemplateRefusals(node.type, node.config).length, |
| 2644 | + buildDoor: validateStackExpressions(stack).filter((i) => i.severity === 'error').length, |
| 2645 | + contract: parsed.success ? 0 : parsed.error.issues.length, |
| 2646 | + lint: lintFlowPatterns(stack).filter((f) => f.rule === FLOW_BARE_DOLLAR_REF || f.rule === FLOW_DOUBLE_BRACE_INTERP).length, |
| 2647 | + }; |
| 2648 | + } |
| 2649 | + const NONE = { valueJudge: 0, buildDoor: 0, contract: 0, lint: 0 }; |
| 2650 | + |
| 2651 | + it('names the CEL envelope, not a `{…}` token the slot refuses (`fields: { owner: \'$User.Id\', who: \'$source.id\' }`)', () => { |
| 2652 | + const fnds = bareDollar(valueFlow('create_record', { objectName: 'task', fields: { owner: '$User.Id', who: '$source.id' } })); |
| 2653 | + expect(fnds).toHaveLength(2); |
| 2654 | + for (const f of fnds) { |
| 2655 | + expect(f.message).toContain('in the create_record field value'); |
| 2656 | + expect(f.hint).toContain('A value slot reads a CEL value envelope'); |
| 2657 | + expect(f.hint).not.toContain('Wrap it and bind a variable'); |
| 2658 | + } |
| 2659 | + const [owner, who] = fnds; |
| 2660 | + // The run user: the judge's remedy for `{$User.Id}` — `current_user.id`, and the guard. |
| 2661 | + expect(owner!.hint).toContain(judgeSpelling('{$User.Id}')); |
| 2662 | + expect(prescribedSources(owner!.hint!)).toEqual(['current_user.id', 'current_user != null ? current_user.id : null']); |
| 2663 | + // `$source` is no `$` variable the engine binds: the flow's own `source`, written without the `$`. |
| 2664 | + expect(who!.hint).toContain(judgeSpelling('{source.id}')); |
| 2665 | + expect(prescribedSources(who!.hint!)).toEqual(['source.id']); |
| 2666 | + }); |
| 2667 | + |
| 2668 | + it('reads a `$` root the engine binds as that variable (`$error.message`)', () => { |
| 2669 | + const [f] = bareDollar(valueFlow('update_record', { objectName: 'task', filter: { id: '{record.id}' }, fields: { note: '$error.message' } })); |
| 2670 | + expect(f!.message).toContain('in the update_record field value'); |
| 2671 | + expect(f!.hint).toContain(judgeSpelling('{$error.message}')); |
| 2672 | + expect(prescribedSources(f!.hint!)).toEqual(['vars["$error"].message']); |
| 2673 | + }); |
| 2674 | + |
| 2675 | + it.each([ |
| 2676 | + ['$User.Id', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })], |
| 2677 | + ['$source.id', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })], |
| 2678 | + ['$User.Email', 'create_record', (v: unknown) => ({ objectName: 'task', fields: { v } })], |
| 2679 | + ['$error.message', 'update_record', (v: unknown) => ({ objectName: 'task', filter: { id: '{record.id}' }, fields: { v } })], |
| 2680 | + ['$User.Id', 'assignment', (v: unknown) => ({ assignments: { v } })], |
| 2681 | + ] as const)('`%s` in a %s value slot: each envelope it prescribes passes every judge with 0 refusals', (ref, type, configOf) => { |
| 2682 | + const fnds = bareDollar(valueFlow(type, configOf(ref))); |
| 2683 | + expect(fnds).toHaveLength(1); |
| 2684 | + const prescribed = prescribedSources(fnds[0]!.hint!); |
| 2685 | + expect(prescribed.length, fnds[0]!.hint).toBeGreaterThan(0); |
| 2686 | + for (const source of prescribed) { |
| 2687 | + expect(refusalsOf(valueFlow(type, configOf(CEL(source)))), `${ref} → ${source}`).toEqual(NONE); |
| 2688 | + } |
| 2689 | + }); |
| 2690 | + |
| 2691 | + it('control: both spellings the hint used to prescribe are refused in the same slot', () => { |
| 2692 | + for (const old of ['{source.id}', '{$User.Id}']) { |
| 2693 | + expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { v: old } })).valueJudge, old).toBe(1); |
| 2694 | + } |
| 2695 | + }); |
| 2696 | + |
| 2697 | + it('control: outside the value and text slots the single brace still resolves, and the hint still names it', () => { |
| 2698 | + const [f] = lintFlowPatterns(httpFlow({ ticket: '$source.id' })).filter((x) => x.rule === FLOW_BARE_DOLLAR_REF); |
| 2699 | + expect(f!.hint).toContain('Wrap it and bind a variable: `{source.id}`'); |
| 2700 | + expect(flowNodeValueTemplateRefusals('http', httpPushConfig({ ticket: '{source.id}' }))).toEqual([]); |
| 2701 | + }); |
2564 | 2702 | }); |
2565 | 2703 | }); |
2566 | 2704 |
|
|
0 commit comments