/ src / pipeline / template.test.ts
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  });