template.test.ts
1 /** 2 * Tests for the pipeline template engine: render, evalExpr, resolvePath. 3 */ 4 5 import { describe, it, expect } from 'vitest'; 6 import { render, evalExpr, resolvePath, normalizeEvaluateSource } from './template.js'; 7 8 describe('resolvePath', () => { 9 it('resolves args path', () => { 10 expect(resolvePath('args.limit', { args: { limit: 20 } })).toBe(20); 11 }); 12 it('resolves nested args path', () => { 13 expect(resolvePath('args.query.keyword', { args: { query: { keyword: 'test' } } })).toBe('test'); 14 }); 15 it('resolves item path', () => { 16 expect(resolvePath('item.title', { item: { title: 'Hello' } })).toBe('Hello'); 17 }); 18 it('resolves implicit item path (no prefix)', () => { 19 expect(resolvePath('title', { item: { title: 'World' } })).toBe('World'); 20 }); 21 it('resolves index', () => { 22 expect(resolvePath('index', { index: 5 })).toBe(5); 23 }); 24 it('resolves data path', () => { 25 expect(resolvePath('data.items', { data: { items: [1, 2, 3] } })).toEqual([1, 2, 3]); 26 }); 27 it('resolves root path', () => { 28 expect(resolvePath('root.items', { root: { items: [1, 2, 3] } })).toEqual([1, 2, 3]); 29 }); 30 it('returns null for missing path', () => { 31 expect(resolvePath('args.missing', { args: {} })).toBeUndefined(); 32 }); 33 it('resolves array index', () => { 34 expect(resolvePath('data.0', { data: ['a', 'b'] })).toBe('a'); 35 }); 36 }); 37 38 describe('evalExpr', () => { 39 it('evaluates default filter', () => { 40 expect(evalExpr('args.limit | default(20)', { args: {} })).toBe(20); 41 }); 42 it('uses actual value over default', () => { 43 expect(evalExpr('args.limit | default(20)', { args: { limit: 10 } })).toBe(10); 44 }); 45 it('evaluates string default', () => { 46 expect(evalExpr("args.name | default('unknown')", { args: {} })).toBe('unknown'); 47 }); 48 it('evaluates arithmetic: index + 1', () => { 49 expect(evalExpr('index + 1', { index: 0 })).toBe(1); 50 }); 51 it('evaluates arithmetic: index * 2', () => { 52 expect(evalExpr('index * 2', { index: 5 })).toBe(10); 53 }); 54 it('evaluates || fallback', () => { 55 expect(evalExpr("item.name || 'N/A'", { item: {} })).toBe('N/A'); 56 }); 57 it('evaluates || with truthy left', () => { 58 expect(evalExpr("item.name || 'N/A'", { item: { name: 'Alice' } })).toBe('Alice'); 59 }); 60 it('evaluates chained || fallback (issue #303)', () => { 61 // When first two are falsy, should evaluate through to the string literal 62 expect(evalExpr("item.a || item.b || 'default'", { item: {} })).toBe('default'); 63 }); 64 it('evaluates chained || with middle value truthy', () => { 65 expect(evalExpr("item.a || item.b || 'default'", { item: { b: 'middle' } })).toBe('middle'); 66 }); 67 it('evaluates chained || with first value truthy', () => { 68 expect(evalExpr("item.a || item.b || 'default'", { item: { a: 'first', b: 'middle' } })).toBe('first'); 69 }); 70 it('evaluates || with 0 as falsy left (JS semantics)', () => { 71 expect(evalExpr("item.count || 'N/A'", { item: { count: 0 } })).toBe('N/A'); 72 }); 73 it('evaluates || with empty string as falsy left', () => { 74 expect(evalExpr("item.name || 'unknown'", { item: { name: '' } })).toBe('unknown'); 75 }); 76 it('evaluates || with numeric fallback returning number type', () => { 77 expect(evalExpr('item.a || 42', { item: {} })).toBe(42); 78 }); 79 it('evaluates 4-way chained ||', () => { 80 expect(evalExpr("item.a || item.b || item.c || 'last'", { item: { c: 'third' } })).toBe('third'); 81 }); 82 it('handles || combined with pipe filter', () => { 83 expect(evalExpr("item.a || item.b | upper", { item: { b: 'hello' } })).toBe('HELLO'); 84 }); 85 it('resolves simple path', () => { 86 expect(evalExpr('item.title', { item: { title: 'Test' } })).toBe('Test'); 87 }); 88 it('evaluates JS helper expressions', () => { 89 expect(evalExpr('encodeURIComponent(args.keyword)', { args: { keyword: 'hello world' } })).toBe('hello%20world'); 90 }); 91 it('evaluates ternary expressions', () => { 92 expect(evalExpr("args.kind === 'tech' ? 'technology' : args.kind", { args: { kind: 'tech' } })).toBe('technology'); 93 }); 94 it('evaluates method calls on values', () => { 95 expect(evalExpr("args.username.startsWith('@') ? args.username : '@' + args.username", { args: { username: 'alice' } })).toBe('@alice'); 96 }); 97 it('rejects constructor-based sandbox escapes', () => { 98 expect(evalExpr("args['cons' + 'tructor']['constructor']('return process')()", { args: {} })).toBeUndefined(); 99 }); 100 it('applies join filter', () => { 101 expect(evalExpr('item.tags | join(,)', { item: { tags: ['a', 'b', 'c'] } })).toBe('a,b,c'); 102 }); 103 it('applies upper filter', () => { 104 expect(evalExpr('item.name | upper', { item: { name: 'hello' } })).toBe('HELLO'); 105 }); 106 it('applies lower filter', () => { 107 expect(evalExpr('item.name | lower', { item: { name: 'HELLO' } })).toBe('hello'); 108 }); 109 it('applies truncate filter', () => { 110 expect(evalExpr('item.text | truncate(5)', { item: { text: 'Hello World!' } })).toBe('Hello...'); 111 }); 112 it('chains filters', () => { 113 expect(evalExpr('item.name | upper | truncate(3)', { item: { name: 'hello' } })).toBe('HEL...'); 114 }); 115 it('applies length filter', () => { 116 expect(evalExpr('item.items | length', { item: { items: [1, 2, 3] } })).toBe(3); 117 }); 118 it('applies json filter to strings with quotes', () => { 119 expect(evalExpr('args.keyword | json', { args: { keyword: "O'Reilly" } })).toBe('"O\'Reilly"'); 120 }); 121 it('applies json filter to nullish values', () => { 122 expect(evalExpr('args.keyword | json', { args: {} })).toBe('null'); 123 }); 124 }); 125 126 describe('render', () => { 127 it('renders full expression', () => { 128 expect(render('${{ args.limit }}', { args: { limit: 30 } })).toBe(30); 129 }); 130 it('renders inline expression in string', () => { 131 expect(render('Hello ${{ item.name }}!', { item: { name: 'World' } })).toBe('Hello World!'); 132 }); 133 it('renders multiple inline expressions', () => { 134 expect(render('${{ item.first }}-${{ item.second }}', { item: { first: 'X', second: 'Y' } })).toBe('X-Y'); 135 }); 136 it('returns non-string values as-is', () => { 137 expect(render(42, {})).toBe(42); 138 expect(render(null, {})).toBeNull(); 139 expect(render(undefined, {})).toBeUndefined(); 140 }); 141 it('returns full expression result as native type', () => { 142 expect(render('${{ args.list }}', { args: { list: [1, 2, 3] } })).toEqual([1, 2, 3]); 143 }); 144 it('renders URL template', () => { 145 expect(render('https://api.example.com/search?q=${{ args.keyword }}', { args: { keyword: 'test' } })).toBe('https://api.example.com/search?q=test'); 146 }); 147 it('renders inline helper expressions', () => { 148 expect(render('https://example.com/search?q=${{ encodeURIComponent(args.keyword) }}', { args: { keyword: 'hello world' } })).toBe('https://example.com/search?q=hello%20world'); 149 }); 150 it('renders full multiline expressions', () => { 151 expect(render("${{\n args.topic ? `https://medium.com/tag/${args.topic}` : 'https://medium.com/tag/technology'\n}}", { args: { topic: 'ai' } })).toBe('https://medium.com/tag/ai'); 152 }); 153 it('renders block expressions with surrounding whitespace', () => { 154 expect(render("\n ${{ args.kind === 'tech' ? 'technology' : args.kind }}\n", { args: { kind: 'tech' } })).toBe('technology'); 155 }); 156 }); 157 158 describe('normalizeEvaluateSource', () => { 159 it('wraps bare expression', () => { 160 expect(normalizeEvaluateSource('document.title')).toBe('() => (document.title)'); 161 }); 162 it('passes through arrow function', () => { 163 expect(normalizeEvaluateSource('() => 42')).toBe('() => 42'); 164 }); 165 it('passes through async arrow function', () => { 166 const src = 'async () => { return 1; }'; 167 expect(normalizeEvaluateSource(src)).toBe(src); 168 }); 169 it('passes through named function', () => { 170 const src = 'function foo() { return 1; }'; 171 expect(normalizeEvaluateSource(src)).toBe(src); 172 }); 173 it('wraps IIFE pattern', () => { 174 const src = '(async () => { return 1; })()'; 175 expect(normalizeEvaluateSource(src)).toBe(`() => (${src})`); 176 }); 177 it('handles empty string', () => { 178 expect(normalizeEvaluateSource('')).toBe('() => undefined'); 179 }); 180 });