From ef79eab44bfd000792c610b968b5ceefd41e76a0 Mon Sep 17 00:00:00 2001 From: Darius Jahandarie Date: Sat, 4 Nov 2023 18:45:57 +0900 Subject: Modernize codebase - Use ES modules - Remove vendored libs and build them from npm using esbuild - Switch from JSZip to zip.js --- dev/lib/handlebars/src/spec/.upstream_git_hash | 1 + dev/lib/handlebars/src/spec/index.basic.test.ts | 481 +++++++++++ dev/lib/handlebars/src/spec/index.blocks.test.ts | 366 ++++++++ dev/lib/handlebars/src/spec/index.builtins.test.ts | 676 +++++++++++++++ dev/lib/handlebars/src/spec/index.compiler.test.ts | 86 ++ dev/lib/handlebars/src/spec/index.data.test.ts | 269 ++++++ dev/lib/handlebars/src/spec/index.helpers.test.ts | 958 +++++++++++++++++++++ dev/lib/handlebars/src/spec/index.partials.test.ts | 591 +++++++++++++ .../handlebars/src/spec/index.regressions.test.ts | 379 ++++++++ dev/lib/handlebars/src/spec/index.security.test.ts | 132 +++ dev/lib/handlebars/src/spec/index.strict.test.ts | 164 ++++ .../src/spec/index.subexpressions.test.ts | 214 +++++ dev/lib/handlebars/src/spec/index.utils.test.ts | 24 + .../src/spec/index.whitespace_control.test.ts | 88 ++ 14 files changed, 4429 insertions(+) create mode 100644 dev/lib/handlebars/src/spec/.upstream_git_hash create mode 100644 dev/lib/handlebars/src/spec/index.basic.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.blocks.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.builtins.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.compiler.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.data.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.helpers.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.partials.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.regressions.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.security.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.strict.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.subexpressions.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.utils.test.ts create mode 100644 dev/lib/handlebars/src/spec/index.whitespace_control.test.ts (limited to 'dev/lib/handlebars/src/spec') diff --git a/dev/lib/handlebars/src/spec/.upstream_git_hash b/dev/lib/handlebars/src/spec/.upstream_git_hash new file mode 100644 index 00000000..5a6b1831 --- /dev/null +++ b/dev/lib/handlebars/src/spec/.upstream_git_hash @@ -0,0 +1 @@ +c65c6cce3f626e4896a9d59250f0908be695adae \ No newline at end of file diff --git a/dev/lib/handlebars/src/spec/index.basic.test.ts b/dev/lib/handlebars/src/spec/index.basic.test.ts new file mode 100644 index 00000000..6acf3ae9 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.basic.test.ts @@ -0,0 +1,481 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('basic context', () => { + it('most basic', () => { + expectTemplate('{{foo}}').withInput({ foo: 'foo' }).toCompileTo('foo'); + }); + + it('escaping', () => { + expectTemplate('\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('{{foo}}'); + expectTemplate('content \\{{foo}}').withInput({ foo: 'food' }).toCompileTo('content {{foo}}'); + expectTemplate('\\\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('\\food'); + expectTemplate('content \\\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('content \\food'); + expectTemplate('\\\\ {{foo}}').withInput({ foo: 'food' }).toCompileTo('\\\\ food'); + }); + + it('compiling with a basic context', () => { + expectTemplate('Goodbye\n{{cruel}}\n{{world}}!') + .withInput({ + cruel: 'cruel', + world: 'world', + }) + .toCompileTo('Goodbye\ncruel\nworld!'); + }); + + it('compiling with a string context', () => { + expectTemplate('{{.}}{{length}}').withInput('bye').toCompileTo('bye3'); + }); + + it('compiling with an undefined context', () => { + expectTemplate('Goodbye\n{{cruel}}\n{{world.bar}}!') + .withInput(undefined) + .toCompileTo('Goodbye\n\n!'); + + expectTemplate('{{#unless foo}}Goodbye{{../test}}{{test2}}{{/unless}}') + .withInput(undefined) + .toCompileTo('Goodbye'); + }); + + it('comments', () => { + expectTemplate('{{! Goodbye}}Goodbye\n{{cruel}}\n{{world}}!') + .withInput({ + cruel: 'cruel', + world: 'world', + }) + .toCompileTo('Goodbye\ncruel\nworld!'); + + expectTemplate(' {{~! comment ~}} blah').toCompileTo('blah'); + expectTemplate(' {{~!-- long-comment --~}} blah').toCompileTo('blah'); + expectTemplate(' {{! comment ~}} blah').toCompileTo(' blah'); + expectTemplate(' {{!-- long-comment --~}} blah').toCompileTo(' blah'); + expectTemplate(' {{~! comment}} blah').toCompileTo(' blah'); + expectTemplate(' {{~!-- long-comment --}} blah').toCompileTo(' blah'); + }); + + it('boolean', () => { + const string = '{{#goodbye}}GOODBYE {{/goodbye}}cruel {{world}}!'; + expectTemplate(string) + .withInput({ + goodbye: true, + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye: false, + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('zeros', () => { + expectTemplate('num1: {{num1}}, num2: {{num2}}') + .withInput({ + num1: 42, + num2: 0, + }) + .toCompileTo('num1: 42, num2: 0'); + + expectTemplate('num: {{.}}').withInput(0).toCompileTo('num: 0'); + + expectTemplate('num: {{num1/num2}}') + .withInput({ num1: { num2: 0 } }) + .toCompileTo('num: 0'); + }); + + it('false', () => { + /* eslint-disable no-new-wrappers */ + expectTemplate('val1: {{val1}}, val2: {{val2}}') + .withInput({ + val1: false, + val2: new Boolean(false), + }) + .toCompileTo('val1: false, val2: false'); + + expectTemplate('val: {{.}}').withInput(false).toCompileTo('val: false'); + + expectTemplate('val: {{val1/val2}}') + .withInput({ val1: { val2: false } }) + .toCompileTo('val: false'); + + expectTemplate('val1: {{{val1}}}, val2: {{{val2}}}') + .withInput({ + val1: false, + val2: new Boolean(false), + }) + .toCompileTo('val1: false, val2: false'); + + expectTemplate('val: {{{val1/val2}}}') + .withInput({ val1: { val2: false } }) + .toCompileTo('val: false'); + /* eslint-enable */ + }); + + it('should handle undefined and null', () => { + expectTemplate('{{awesome undefined null}}') + .withInput({ + awesome(_undefined: any, _null: any, options: any) { + return (_undefined === undefined) + ' ' + (_null === null) + ' ' + typeof options; + }, + }) + .toCompileTo('true true object'); + + expectTemplate('{{undefined}}') + .withInput({ + undefined() { + return 'undefined!'; + }, + }) + .toCompileTo('undefined!'); + + expectTemplate('{{null}}') + .withInput({ + null() { + return 'null!'; + }, + }) + .toCompileTo('null!'); + }); + + it('newlines', () => { + expectTemplate("Alan's\nTest").toCompileTo("Alan's\nTest"); + expectTemplate("Alan's\rTest").toCompileTo("Alan's\rTest"); + }); + + it('escaping text', () => { + expectTemplate("Awesome's").toCompileTo("Awesome's"); + expectTemplate('Awesome\\').toCompileTo('Awesome\\'); + expectTemplate('Awesome\\\\ foo').toCompileTo('Awesome\\\\ foo'); + expectTemplate('Awesome {{foo}}').withInput({ foo: '\\' }).toCompileTo('Awesome \\'); + expectTemplate(" ' ' ").toCompileTo(" ' ' "); + }); + + it('escaping expressions', () => { + expectTemplate('{{{awesome}}}').withInput({ awesome: "&'\\<>" }).toCompileTo("&'\\<>"); + + expectTemplate('{{&awesome}}').withInput({ awesome: "&'\\<>" }).toCompileTo("&'\\<>"); + + expectTemplate('{{awesome}}') + .withInput({ awesome: '&"\'`\\<>' }) + .toCompileTo('&"'`\\<>'); + + expectTemplate('{{awesome}}') + .withInput({ awesome: 'Escaped, looks like: <b>' }) + .toCompileTo('Escaped, <b> looks like: &lt;b&gt;'); + }); + + it("functions returning safestrings shouldn't be escaped", () => { + expectTemplate('{{awesome}}') + .withInput({ + awesome() { + return new Handlebars.SafeString("&'\\<>"); + }, + }) + .toCompileTo("&'\\<>"); + }); + + it('functions', () => { + expectTemplate('{{awesome}}') + .withInput({ + awesome() { + return 'Awesome'; + }, + }) + .toCompileTo('Awesome'); + + expectTemplate('{{awesome}}') + .withInput({ + awesome() { + return this.more; + }, + more: 'More awesome', + }) + .toCompileTo('More awesome'); + }); + + it('functions with context argument', () => { + expectTemplate('{{awesome frank}}') + .withInput({ + awesome(context: any) { + return context; + }, + frank: 'Frank', + }) + .toCompileTo('Frank'); + }); + + it('pathed functions with context argument', () => { + expectTemplate('{{bar.awesome frank}}') + .withInput({ + bar: { + awesome(context: any) { + return context; + }, + }, + frank: 'Frank', + }) + .toCompileTo('Frank'); + }); + + it('depthed functions with context argument', () => { + expectTemplate('{{#with frank}}{{../awesome .}}{{/with}}') + .withInput({ + awesome(context: any) { + return context; + }, + frank: 'Frank', + }) + .toCompileTo('Frank'); + }); + + it('block functions with context argument', () => { + expectTemplate('{{#awesome 1}}inner {{.}}{{/awesome}}') + .withInput({ + awesome(context: any, options: any) { + return options.fn(context); + }, + }) + .toCompileTo('inner 1'); + }); + + it('depthed block functions with context argument', () => { + expectTemplate('{{#with value}}{{#../awesome 1}}inner {{.}}{{/../awesome}}{{/with}}') + .withInput({ + value: true, + awesome(context: any, options: any) { + return options.fn(context); + }, + }) + .toCompileTo('inner 1'); + }); + + it('block functions without context argument', () => { + expectTemplate('{{#awesome}}inner{{/awesome}}') + .withInput({ + awesome(options: any) { + return options.fn(this); + }, + }) + .toCompileTo('inner'); + }); + + it('pathed block functions without context argument', () => { + expectTemplate('{{#foo.awesome}}inner{{/foo.awesome}}') + .withInput({ + foo: { + awesome() { + return this; + }, + }, + }) + .toCompileTo('inner'); + }); + + it('depthed block functions without context argument', () => { + expectTemplate('{{#with value}}{{#../awesome}}inner{{/../awesome}}{{/with}}') + .withInput({ + value: true, + awesome() { + return this; + }, + }) + .toCompileTo('inner'); + }); + + it('paths with hyphens', () => { + expectTemplate('{{foo-bar}}').withInput({ 'foo-bar': 'baz' }).toCompileTo('baz'); + + expectTemplate('{{foo.foo-bar}}') + .withInput({ foo: { 'foo-bar': 'baz' } }) + .toCompileTo('baz'); + + expectTemplate('{{foo/foo-bar}}') + .withInput({ foo: { 'foo-bar': 'baz' } }) + .toCompileTo('baz'); + }); + + it('nested paths', () => { + expectTemplate('Goodbye {{alan/expression}} world!') + .withInput({ alan: { expression: 'beautiful' } }) + .toCompileTo('Goodbye beautiful world!'); + }); + + it('nested paths with empty string value', () => { + expectTemplate('Goodbye {{alan/expression}} world!') + .withInput({ alan: { expression: '' } }) + .toCompileTo('Goodbye world!'); + }); + + it('literal paths', () => { + expectTemplate('Goodbye {{[@alan]/expression}} world!') + .withInput({ '@alan': { expression: 'beautiful' } }) + .toCompileTo('Goodbye beautiful world!'); + + expectTemplate('Goodbye {{[foo bar]/expression}} world!') + .withInput({ 'foo bar': { expression: 'beautiful' } }) + .toCompileTo('Goodbye beautiful world!'); + }); + + it('literal references', () => { + expectTemplate('Goodbye {{[foo bar]}} world!') + .withInput({ 'foo bar': 'beautiful' }) + .toCompileTo('Goodbye beautiful world!'); + + expectTemplate('Goodbye {{"foo bar"}} world!') + .withInput({ 'foo bar': 'beautiful' }) + .toCompileTo('Goodbye beautiful world!'); + + expectTemplate("Goodbye {{'foo bar'}} world!") + .withInput({ 'foo bar': 'beautiful' }) + .toCompileTo('Goodbye beautiful world!'); + + expectTemplate('Goodbye {{"foo[bar"}} world!') + .withInput({ 'foo[bar': 'beautiful' }) + .toCompileTo('Goodbye beautiful world!'); + + expectTemplate('Goodbye {{"foo\'bar"}} world!') + .withInput({ "foo'bar": 'beautiful' }) + .toCompileTo('Goodbye beautiful world!'); + + expectTemplate("Goodbye {{'foo\"bar'}} world!") + .withInput({ 'foo"bar': 'beautiful' }) + .toCompileTo('Goodbye beautiful world!'); + }); + + it("that current context path ({{.}}) doesn't hit helpers", () => { + expectTemplate('test: {{.}}') + .withInput(null) + // @ts-expect-error Setting the helper to a string instead of a function doesn't make sense normally, but here it doesn't matter + .withHelpers({ helper: 'awesome' }) + .toCompileTo('test: '); + }); + + it('complex but empty paths', () => { + expectTemplate('{{person/name}}') + .withInput({ person: { name: null } }) + .toCompileTo(''); + + expectTemplate('{{person/name}}').withInput({ person: {} }).toCompileTo(''); + }); + + it('this keyword in paths', () => { + expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}') + .withInput({ goodbyes: ['goodbye', 'Goodbye', 'GOODBYE'] }) + .toCompileTo('goodbyeGoodbyeGOODBYE'); + + expectTemplate('{{#hellos}}{{this/text}}{{/hellos}}') + .withInput({ + hellos: [{ text: 'hello' }, { text: 'Hello' }, { text: 'HELLO' }], + }) + .toCompileTo('helloHelloHELLO'); + }); + + it('this keyword nested inside path', () => { + expectTemplate('{{#hellos}}{{text/this/foo}}{{/hellos}}').toThrow( + 'Invalid path: text/this - 1:13' + ); + + expectTemplate('{{[this]}}').withInput({ this: 'bar' }).toCompileTo('bar'); + + expectTemplate('{{text/[this]}}') + .withInput({ text: { this: 'bar' } }) + .toCompileTo('bar'); + }); + + it('this keyword in helpers', () => { + const helpers = { + foo(value: any) { + return 'bar ' + value; + }, + }; + + expectTemplate('{{#goodbyes}}{{foo this}}{{/goodbyes}}') + .withInput({ goodbyes: ['goodbye', 'Goodbye', 'GOODBYE'] }) + .withHelpers(helpers) + .toCompileTo('bar goodbyebar Goodbyebar GOODBYE'); + + expectTemplate('{{#hellos}}{{foo this/text}}{{/hellos}}') + .withInput({ + hellos: [{ text: 'hello' }, { text: 'Hello' }, { text: 'HELLO' }], + }) + .withHelpers(helpers) + .toCompileTo('bar hellobar Hellobar HELLO'); + }); + + it('this keyword nested inside helpers param', () => { + expectTemplate('{{#hellos}}{{foo text/this/foo}}{{/hellos}}').toThrow( + 'Invalid path: text/this - 1:17' + ); + + expectTemplate('{{foo [this]}}') + .withInput({ + foo(value: any) { + return value; + }, + this: 'bar', + }) + .toCompileTo('bar'); + + expectTemplate('{{foo text/[this]}}') + .withInput({ + foo(value: any) { + return value; + }, + text: { this: 'bar' }, + }) + .toCompileTo('bar'); + }); + + it('pass string literals', () => { + expectTemplate('{{"foo"}}').toCompileTo(''); + expectTemplate('{{"foo"}}').withInput({ foo: 'bar' }).toCompileTo('bar'); + + expectTemplate('{{#"foo"}}{{.}}{{/"foo"}}') + .withInput({ + foo: ['bar', 'baz'], + }) + .toCompileTo('barbaz'); + }); + + it('pass number literals', () => { + expectTemplate('{{12}}').toCompileTo(''); + expectTemplate('{{12}}').withInput({ '12': 'bar' }).toCompileTo('bar'); + expectTemplate('{{12.34}}').toCompileTo(''); + expectTemplate('{{12.34}}').withInput({ '12.34': 'bar' }).toCompileTo('bar'); + expectTemplate('{{12.34 1}}') + .withInput({ + '12.34'(arg: any) { + return 'bar' + arg; + }, + }) + .toCompileTo('bar1'); + }); + + it('pass boolean literals', () => { + expectTemplate('{{true}}').toCompileTo(''); + expectTemplate('{{true}}').withInput({ '': 'foo' }).toCompileTo(''); + expectTemplate('{{false}}').withInput({ false: 'foo' }).toCompileTo('foo'); + }); + + it('should handle literals in subexpression', () => { + expectTemplate('{{foo (false)}}') + .withInput({ + false() { + return 'bar'; + }, + }) + .withHelper('foo', function (arg) { + return arg; + }) + .toCompileTo('bar'); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.blocks.test.ts b/dev/lib/handlebars/src/spec/index.blocks.test.ts new file mode 100644 index 00000000..2d9a8707 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.blocks.test.ts @@ -0,0 +1,366 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('blocks', () => { + it('array', () => { + const string = '{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + + expectTemplate(string) + .withInput({ + goodbyes: [], + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('array without data', () => { + expectTemplate('{{#goodbyes}}{{text}}{{/goodbyes}} {{#goodbyes}}{{text}}{{/goodbyes}}') + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('goodbyeGoodbyeGOODBYE goodbyeGoodbyeGOODBYE'); + }); + + it('array with @index', () => { + expectTemplate('{{#goodbyes}}{{@index}}. {{text}}! {{/goodbyes}}cruel {{world}}!') + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); + }); + + it('empty block', () => { + const string = '{{#goodbyes}}{{/goodbyes}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('cruel world!'); + + expectTemplate(string) + .withInput({ + goodbyes: [], + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('block with complex lookup', () => { + expectTemplate('{{#goodbyes}}{{text}} cruel {{../name}}! {{/goodbyes}}') + .withInput({ + name: 'Alan', + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + }) + .toCompileTo('goodbye cruel Alan! Goodbye cruel Alan! GOODBYE cruel Alan! '); + }); + + it('multiple blocks with complex lookup', () => { + expectTemplate('{{#goodbyes}}{{../name}}{{../name}}{{/goodbyes}}') + .withInput({ + name: 'Alan', + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + }) + .toCompileTo('AlanAlanAlanAlanAlanAlan'); + }); + + it('block with complex lookup using nested context', () => { + expectTemplate('{{#goodbyes}}{{text}} cruel {{foo/../name}}! {{/goodbyes}}').toThrow(Error); + }); + + it('block with deep nested complex lookup', () => { + expectTemplate( + '{{#outer}}Goodbye {{#inner}}cruel {{../sibling}} {{../../omg}}{{/inner}}{{/outer}}' + ) + .withInput({ + omg: 'OMG!', + outer: [{ sibling: 'sad', inner: [{ text: 'goodbye' }] }], + }) + .toCompileTo('Goodbye cruel sad OMG!'); + }); + + it('works with cached blocks', () => { + expectTemplate('{{#each person}}{{#with .}}{{first}} {{last}}{{/with}}{{/each}}') + .withCompileOptions({ data: false }) + .withInput({ + person: [ + { first: 'Alan', last: 'Johnson' }, + { first: 'Alan', last: 'Johnson' }, + ], + }) + .toCompileTo('Alan JohnsonAlan Johnson'); + }); + + describe('inverted sections', () => { + it('inverted sections with unset value', () => { + expectTemplate( + '{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}' + ).toCompileTo('Right On!'); + }); + + it('inverted section with false value', () => { + expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}') + .withInput({ goodbyes: false }) + .toCompileTo('Right On!'); + }); + + it('inverted section with empty set', () => { + expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}') + .withInput({ goodbyes: [] }) + .toCompileTo('Right On!'); + }); + + it('block inverted sections', () => { + expectTemplate('{{#people}}{{name}}{{^}}{{none}}{{/people}}') + .withInput({ none: 'No people' }) + .toCompileTo('No people'); + }); + + it('chained inverted sections', () => { + expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/people}}') + .withInput({ none: 'No people' }) + .toCompileTo('No people'); + + expectTemplate( + '{{#people}}{{name}}{{else if nothere}}fail{{else unless nothere}}{{none}}{{/people}}' + ) + .withInput({ none: 'No people' }) + .toCompileTo('No people'); + + expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{else}}fail{{/people}}') + .withInput({ none: 'No people' }) + .toCompileTo('No people'); + }); + + it('chained inverted sections with mismatch', () => { + expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/if}}').toThrow(Error); + }); + + it('block inverted sections with empty arrays', () => { + expectTemplate('{{#people}}{{name}}{{^}}{{none}}{{/people}}') + .withInput({ + none: 'No people', + people: [], + }) + .toCompileTo('No people'); + }); + }); + + describe('standalone sections', () => { + it('block standalone else sections', () => { + expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n') + .withInput({ none: 'No people' }) + .toCompileTo('No people\n'); + + expectTemplate('{{#none}}\n{{.}}\n{{^}}\n{{none}}\n{{/none}}\n') + .withInput({ none: 'No people' }) + .toCompileTo('No people\n'); + + expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n') + .withInput({ none: 'No people' }) + .toCompileTo('No people\n'); + }); + + it('block standalone chained else sections', () => { + expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{/people}}\n') + .withInput({ none: 'No people' }) + .toCompileTo('No people\n'); + + expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{^}}\n{{/people}}\n') + .withInput({ none: 'No people' }) + .toCompileTo('No people\n'); + }); + + it('should handle nesting', () => { + expectTemplate('{{#data}}\n{{#if true}}\n{{.}}\n{{/if}}\n{{/data}}\nOK.') + .withInput({ + data: [1, 3, 5], + }) + .toCompileTo('1\n3\n5\nOK.'); + }); + }); + + describe('decorators', () => { + it('should apply mustache decorators', () => { + expectTemplate('{{#helper}}{{*decorator}}{{/helper}}') + .withHelper('helper', function (options: HelperOptions) { + return (options.fn as any).run; + }) + .withDecorator('decorator', function (fn) { + (fn as any).run = 'success'; + return fn; + }) + .toCompileTo('success'); + }); + + it('should apply allow undefined return', () => { + expectTemplate('{{#helper}}{{*decorator}}suc{{/helper}}') + .withHelper('helper', function (options: HelperOptions) { + return options.fn() + (options.fn as any).run; + }) + .withDecorator('decorator', function (fn) { + (fn as any).run = 'cess'; + }) + .toCompileTo('success'); + }); + + it('should apply block decorators', () => { + expectTemplate('{{#helper}}{{#*decorator}}success{{/decorator}}{{/helper}}') + .withHelper('helper', function (options: HelperOptions) { + return (options.fn as any).run; + }) + .withDecorator('decorator', function (fn, props, container, options) { + (fn as any).run = options.fn(); + return fn; + }) + .toCompileTo('success'); + }); + + it('should support nested decorators', () => { + expectTemplate( + '{{#helper}}{{#*decorator}}{{#*nested}}suc{{/nested}}cess{{/decorator}}{{/helper}}' + ) + .withHelper('helper', function (options: HelperOptions) { + return (options.fn as any).run; + }) + .withDecorators({ + decorator(fn, props, container, options) { + (fn as any).run = options.fn.nested + options.fn(); + return fn; + }, + nested(fn, props, container, options) { + props.nested = options.fn(); + }, + }) + .toCompileTo('success'); + }); + + it('should apply multiple decorators', () => { + expectTemplate( + '{{#helper}}{{#*decorator}}suc{{/decorator}}{{#*decorator}}cess{{/decorator}}{{/helper}}' + ) + .withHelper('helper', function (options: HelperOptions) { + return (options.fn as any).run; + }) + .withDecorator('decorator', function (fn, props, container, options) { + (fn as any).run = ((fn as any).run || '') + options.fn(); + return fn; + }) + .toCompileTo('success'); + }); + + it('should access parent variables', () => { + expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}') + .withHelper('helper', function (options: HelperOptions) { + return (options.fn as any).run; + }) + .withDecorator('decorator', function (fn, props, container, options) { + (fn as any).run = options.args; + return fn; + }) + .withInput({ foo: 'success' }) + .toCompileTo('success'); + }); + + it('should work with root program', () => { + let run; + expectTemplate('{{*decorator "success"}}') + .withDecorator('decorator', function (fn, props, container, options) { + expect(options.args[0]).toEqual('success'); + run = true; + return fn; + }) + .withInput({ foo: 'success' }) + .toCompileTo(''); + expect(run).toEqual(true); + }); + + it('should fail when accessing variables from root', () => { + let run; + expectTemplate('{{*decorator foo}}') + .withDecorator('decorator', function (fn, props, container, options) { + expect(options.args[0]).toBeUndefined(); + run = true; + return fn; + }) + .withInput({ foo: 'fail' }) + .toCompileTo(''); + expect(run).toEqual(true); + }); + + describe('registration', () => { + beforeEach(() => { + global.kbnHandlebarsEnv = Handlebars.create(); + }); + + afterEach(() => { + global.kbnHandlebarsEnv = null; + }); + + it('unregisters', () => { + // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property. + kbnHandlebarsEnv!.decorators = {}; + + kbnHandlebarsEnv!.registerDecorator('foo', function () { + return 'fail'; + }); + + expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true); + kbnHandlebarsEnv!.unregisterDecorator('foo'); + expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined(); + }); + + it('allows multiple globals', () => { + // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property. + kbnHandlebarsEnv!.decorators = {}; + + // @ts-expect-error: Expected 2 arguments, but got 1. + kbnHandlebarsEnv!.registerDecorator({ + foo() {}, + bar() {}, + }); + + expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true); + expect(!!kbnHandlebarsEnv!.decorators.bar).toEqual(true); + kbnHandlebarsEnv!.unregisterDecorator('foo'); + kbnHandlebarsEnv!.unregisterDecorator('bar'); + expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined(); + expect(kbnHandlebarsEnv!.decorators.bar).toBeUndefined(); + }); + + it('fails with multiple and args', () => { + expect(() => { + kbnHandlebarsEnv!.registerDecorator( + // @ts-expect-error: Argument of type '{ world(): string; testHelper(): string; }' is not assignable to parameter of type 'string'. + { + world() { + return 'world!'; + }, + testHelper() { + return 'found it!'; + }, + }, + {} + ); + }).toThrow('Arg not supported with multiple decorators'); + }); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.builtins.test.ts b/dev/lib/handlebars/src/spec/index.builtins.test.ts new file mode 100644 index 00000000..c47ec29f --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.builtins.test.ts @@ -0,0 +1,676 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +/* eslint-disable max-classes-per-file */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('builtin helpers', () => { + describe('#if', () => { + it('if', () => { + const string = '{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbye: true, + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye: 'dummy', + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye: false, + world: 'world', + }) + .toCompileTo('cruel world!'); + + expectTemplate(string).withInput({ world: 'world' }).toCompileTo('cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye: ['foo'], + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye: [], + world: 'world', + }) + .toCompileTo('cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye: 0, + world: 'world', + }) + .toCompileTo('cruel world!'); + + expectTemplate('{{#if goodbye includeZero=true}}GOODBYE {{/if}}cruel {{world}}!') + .withInput({ + goodbye: 0, + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + }); + + it('if with function argument', () => { + const string = '{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbye() { + return true; + }, + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye() { + return this.world; + }, + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye() { + return false; + }, + world: 'world', + }) + .toCompileTo('cruel world!'); + + expectTemplate(string) + .withInput({ + goodbye() { + return this.foo; + }, + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('should not change the depth list', () => { + expectTemplate('{{#with foo}}{{#if goodbye}}GOODBYE cruel {{../world}}!{{/if}}{{/with}}') + .withInput({ + foo: { goodbye: true }, + world: 'world', + }) + .toCompileTo('GOODBYE cruel world!'); + }); + }); + + describe('#with', () => { + it('with', () => { + expectTemplate('{{#with person}}{{first}} {{last}}{{/with}}') + .withInput({ + person: { + first: 'Alan', + last: 'Johnson', + }, + }) + .toCompileTo('Alan Johnson'); + }); + + it('with with function argument', () => { + expectTemplate('{{#with person}}{{first}} {{last}}{{/with}}') + .withInput({ + person() { + return { + first: 'Alan', + last: 'Johnson', + }; + }, + }) + .toCompileTo('Alan Johnson'); + }); + + it('with with else', () => { + expectTemplate( + '{{#with person}}Person is present{{else}}Person is not present{{/with}}' + ).toCompileTo('Person is not present'); + }); + + it('with provides block parameter', () => { + expectTemplate('{{#with person as |foo|}}{{foo.first}} {{last}}{{/with}}') + .withInput({ + person: { + first: 'Alan', + last: 'Johnson', + }, + }) + .toCompileTo('Alan Johnson'); + }); + + it('works when data is disabled', () => { + expectTemplate('{{#with person as |foo|}}{{foo.first}} {{last}}{{/with}}') + .withInput({ person: { first: 'Alan', last: 'Johnson' } }) + .withCompileOptions({ data: false }) + .toCompileTo('Alan Johnson'); + }); + }); + + describe('#each', () => { + it('each', () => { + const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + + expectTemplate(string) + .withInput({ + goodbyes: [], + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('each without data', () => { + expectTemplate('{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!') + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .withRuntimeOptions({ data: false }) + .withCompileOptions({ data: false }) + .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + + expectTemplate('{{#each .}}{{.}}{{/each}}') + .withInput({ goodbyes: 'cruel', world: 'world' }) + .withRuntimeOptions({ data: false }) + .withCompileOptions({ data: false }) + .toCompileTo('cruelworld'); + }); + + it('each without context', () => { + expectTemplate('{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!') + .withInput(undefined) + .toCompileTo('cruel !'); + }); + + it('each with an object and @key', () => { + const string = '{{#each goodbyes}}{{@key}}. {{text}}! {{/each}}cruel {{world}}!'; + + function Clazz(this: any) { + this['#1'] = { text: 'goodbye' }; + this[2] = { text: 'GOODBYE' }; + } + Clazz.prototype.foo = 'fail'; + const hash = { goodbyes: new (Clazz as any)(), world: 'world' }; + + // Object property iteration order is undefined according to ECMA spec, + // so we need to check both possible orders + // @see http://stackoverflow.com/questions/280713/elements-order-in-a-for-in-loop + try { + expectTemplate(string) + .withInput(hash) + .toCompileTo('<b>#1</b>. goodbye! 2. GOODBYE! cruel world!'); + } catch (e) { + expectTemplate(string) + .withInput(hash) + .toCompileTo('2. GOODBYE! <b>#1</b>. goodbye! cruel world!'); + } + + expectTemplate(string) + .withInput({ + goodbyes: {}, + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('each with @index', () => { + expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!') + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); + }); + + it('each with nested @index', () => { + expectTemplate( + '{{#each goodbyes}}{{@index}}. {{text}}! {{#each ../goodbyes}}{{@index}} {{/each}}After {{@index}} {{/each}}{{@index}}cruel {{world}}!' + ) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo( + '0. goodbye! 0 1 2 After 0 1. Goodbye! 0 1 2 After 1 2. GOODBYE! 0 1 2 After 2 cruel world!' + ); + }); + + it('each with block params', () => { + expectTemplate( + '{{#each goodbyes as |value index|}}{{index}}. {{value.text}}! {{#each ../goodbyes as |childValue childIndex|}} {{index}} {{childIndex}}{{/each}} After {{index}} {{/each}}{{index}}cruel {{world}}!' + ) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }], + world: 'world', + }) + .toCompileTo('0. goodbye! 0 0 0 1 After 0 1. Goodbye! 1 0 1 1 After 1 cruel world!'); + }); + + // TODO: This test has been added to the `4.x` branch of the handlebars.js repo along with a code-fix, + // but a new version of the handlebars package containing this fix has not yet been published to npm. + // + // Before enabling this code, a new version of handlebars needs to be released and the corresponding + // updates needs to be applied to this implementation. + // + // See: https://github.com/handlebars-lang/handlebars.js/commit/30dbf0478109ded8f12bb29832135d480c17e367 + it.skip('each with block params and strict compilation', () => { + expectTemplate('{{#each goodbyes as |value index|}}{{index}}. {{value.text}}!{{/each}}') + .withCompileOptions({ strict: true }) + .withInput({ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }] }) + .toCompileTo('0. goodbye!1. Goodbye!'); + }); + + it('each object with @index', () => { + expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!') + .withInput({ + goodbyes: { + a: { text: 'goodbye' }, + b: { text: 'Goodbye' }, + c: { text: 'GOODBYE' }, + }, + world: 'world', + }) + .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); + }); + + it('each with @first', () => { + expectTemplate('{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('goodbye! cruel world!'); + }); + + it('each with nested @first', () => { + expectTemplate( + '{{#each goodbyes}}({{#if @first}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @first}}{{text}}!{{/if}}{{/each}}{{#if @first}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!' + ) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('(goodbye! goodbye! goodbye!) (goodbye!) (goodbye!) cruel world!'); + }); + + it('each object with @first', () => { + expectTemplate('{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') + .withInput({ + goodbyes: { foo: { text: 'goodbye' }, bar: { text: 'Goodbye' } }, + world: 'world', + }) + .toCompileTo('goodbye! cruel world!'); + }); + + it('each with @last', () => { + expectTemplate('{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('GOODBYE! cruel world!'); + }); + + it('each object with @last', () => { + expectTemplate('{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') + .withInput({ + goodbyes: { foo: { text: 'goodbye' }, bar: { text: 'Goodbye' } }, + world: 'world', + }) + .toCompileTo('Goodbye! cruel world!'); + }); + + it('each with nested @last', () => { + expectTemplate( + '{{#each goodbyes}}({{#if @last}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @last}}{{text}}!{{/if}}{{/each}}{{#if @last}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!' + ) + .withInput({ + goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], + world: 'world', + }) + .toCompileTo('(GOODBYE!) (GOODBYE!) (GOODBYE! GOODBYE! GOODBYE!) cruel world!'); + }); + + it('each with function argument', () => { + const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbyes() { + return [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }]; + }, + world: 'world', + }) + .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + + expectTemplate(string) + .withInput({ + goodbyes: [], + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + + it('each object when last key is an empty string', () => { + expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!') + .withInput({ + goodbyes: { + a: { text: 'goodbye' }, + b: { text: 'Goodbye' }, + '': { text: 'GOODBYE' }, + }, + world: 'world', + }) + .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); + }); + + it('data passed to helpers', () => { + expectTemplate('{{#each letters}}{{this}}{{detectDataInsideEach}}{{/each}}') + .withInput({ letters: ['a', 'b', 'c'] }) + .withHelper('detectDataInsideEach', function (options) { + return options.data && options.data.exclaim; + }) + .withRuntimeOptions({ + data: { + exclaim: '!', + }, + }) + .toCompileTo('a!b!c!'); + }); + + it('each on implicit context', () => { + expectTemplate('{{#each}}{{text}}! {{/each}}cruel world!').toThrow(Handlebars.Exception); + }); + + it('each on iterable', () => { + class Iterator { + private arr: any[]; + private index: number = 0; + + constructor(arr: any[]) { + this.arr = arr; + } + + next() { + const value = this.arr[this.index]; + const done = this.index === this.arr.length; + if (!done) { + this.index++; + } + return { value, done }; + } + } + + class Iterable { + private arr: any[]; + + constructor(arr: any[]) { + this.arr = arr; + } + + [Symbol.iterator]() { + return new Iterator(this.arr); + } + } + + const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!'; + + expectTemplate(string) + .withInput({ + goodbyes: new Iterable([{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }]), + world: 'world', + }) + .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + + expectTemplate(string) + .withInput({ + goodbyes: new Iterable([]), + world: 'world', + }) + .toCompileTo('cruel world!'); + }); + }); + + describe('#log', function () { + /* eslint-disable no-console */ + let $log: typeof console.log; + let $info: typeof console.info; + let $error: typeof console.error; + + beforeEach(function () { + $log = console.log; + $info = console.info; + $error = console.error; + + global.kbnHandlebarsEnv = Handlebars.create(); + }); + + afterEach(function () { + console.log = $log; + console.info = $info; + console.error = $error; + + global.kbnHandlebarsEnv = null; + }); + + it('should call logger at default level', function () { + let levelArg; + let logArg; + kbnHandlebarsEnv!.log = function (level, arg) { + levelArg = level; + logArg = arg; + }; + + expectTemplate('{{log blah}}').withInput({ blah: 'whee' }).toCompileTo(''); + expect(1).toEqual(levelArg); + expect('whee').toEqual(logArg); + }); + + it('should call logger at data level', function () { + let levelArg; + let logArg; + kbnHandlebarsEnv!.log = function (level, arg) { + levelArg = level; + logArg = arg; + }; + + expectTemplate('{{log blah}}') + .withInput({ blah: 'whee' }) + .withRuntimeOptions({ data: { level: '03' } }) + .withCompileOptions({ data: true }) + .toCompileTo(''); + expect('03').toEqual(levelArg); + expect('whee').toEqual(logArg); + }); + + it('should output to info', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + console.info = function (info) { + expect('whee').toEqual(info); + calls++; + if (calls === callsExpected) { + console.info = $info; + console.log = $log; + } + }; + console.log = function (log) { + expect('whee').toEqual(log); + calls++; + if (calls === callsExpected) { + console.info = $info; + console.log = $log; + } + }; + + expectTemplate('{{log blah}}').withInput({ blah: 'whee' }).toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + + it('should log at data level', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + console.error = function (log) { + expect('whee').toEqual(log); + calls++; + if (calls === callsExpected) console.error = $error; + }; + + expectTemplate('{{log blah}}') + .withInput({ blah: 'whee' }) + .withRuntimeOptions({ data: { level: '03' } }) + .withCompileOptions({ data: true }) + .toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + + it('should handle missing logger', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + // @ts-expect-error + console.error = undefined; + console.log = function (log) { + expect('whee').toEqual(log); + calls++; + if (calls === callsExpected) console.log = $log; + }; + + expectTemplate('{{log blah}}') + .withInput({ blah: 'whee' }) + .withRuntimeOptions({ data: { level: '03' } }) + .withCompileOptions({ data: true }) + .toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + + it('should handle string log levels', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + console.error = function (log) { + expect('whee').toEqual(log); + calls++; + }; + + expectTemplate('{{log blah}}') + .withInput({ blah: 'whee' }) + .withRuntimeOptions({ data: { level: 'error' } }) + .withCompileOptions({ data: true }) + .toCompileTo(''); + expect(calls).toEqual(callsExpected); + + calls = 0; + + expectTemplate('{{log blah}}') + .withInput({ blah: 'whee' }) + .withRuntimeOptions({ data: { level: 'ERROR' } }) + .withCompileOptions({ data: true }) + .toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + + it('should handle hash log levels [1]', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + console.error = function (log) { + expect('whee').toEqual(log); + calls++; + }; + + expectTemplate('{{log blah level="error"}}').withInput({ blah: 'whee' }).toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + + it('should handle hash log levels [2]', function () { + let called = false; + + console.info = + console.log = + console.error = + console.debug = + function () { + called = true; + console.info = console.log = console.error = console.debug = $log; + }; + + expectTemplate('{{log blah level="debug"}}').withInput({ blah: 'whee' }).toCompileTo(''); + expect(false).toEqual(called); + }); + + it('should pass multiple log arguments', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + console.info = console.log = function (log1, log2, log3) { + expect('whee').toEqual(log1); + expect('foo').toEqual(log2); + expect(1).toEqual(log3); + calls++; + if (calls === callsExpected) console.log = $log; + }; + + expectTemplate('{{log blah "foo" 1}}').withInput({ blah: 'whee' }).toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + + it('should pass zero log arguments', function () { + let calls = 0; + const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + + console.info = console.log = function () { + expect(arguments.length).toEqual(0); + calls++; + if (calls === callsExpected) console.log = $log; + }; + + expectTemplate('{{log}}').withInput({ blah: 'whee' }).toCompileTo(''); + expect(calls).toEqual(callsExpected); + }); + /* eslint-enable no-console */ + }); + + describe('#lookup', () => { + it('should lookup arbitrary content', () => { + expectTemplate('{{#each goodbyes}}{{lookup ../data .}}{{/each}}') + .withInput({ goodbyes: [0, 1], data: ['foo', 'bar'] }) + .toCompileTo('foobar'); + }); + + it('should not fail on undefined value', () => { + expectTemplate('{{#each goodbyes}}{{lookup ../bar .}}{{/each}}') + .withInput({ goodbyes: [0, 1], data: ['foo', 'bar'] }) + .toCompileTo(''); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.compiler.test.ts b/dev/lib/handlebars/src/spec/index.compiler.test.ts new file mode 100644 index 00000000..ef5c55f2 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.compiler.test.ts @@ -0,0 +1,86 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { forEachCompileFunctionName } from '../__jest__/test_bench'; + +describe('compiler', () => { + forEachCompileFunctionName((compileName) => { + const compile = Handlebars[compileName].bind(Handlebars); + + describe(`#${compileName}`, () => { + it('should fail with invalid input', () => { + expect(function () { + compile(null); + }).toThrow( + `You must pass a string or Handlebars AST to Handlebars.${compileName}. You passed null` + ); + + expect(function () { + compile({}); + }).toThrow( + `You must pass a string or Handlebars AST to Handlebars.${compileName}. You passed [object Object]` + ); + }); + + it('should include the location in the error (row and column)', () => { + try { + compile(' \n {{#if}}\n{{/def}}')(); + expect(true).toEqual(false); + } catch (err) { + expect(err.message).toEqual("if doesn't match def - 2:5"); + if (Object.getOwnPropertyDescriptor(err, 'column')!.writable) { + // In Safari 8, the column-property is read-only. This means that even if it is set with defineProperty, + // its value won't change (https://github.com/jquery/esprima/issues/1290#issuecomment-132455482) + // Since this was neither working in Handlebars 3 nor in 4.0.5, we only check the column for other browsers. + expect(err.column).toEqual(5); + } + expect(err.lineNumber).toEqual(2); + } + }); + + it('should include the location as enumerable property', () => { + try { + compile(' \n {{#if}}\n{{/def}}')(); + expect(true).toEqual(false); + } catch (err) { + expect(Object.prototype.propertyIsEnumerable.call(err, 'column')).toEqual(true); + } + }); + + it('can utilize AST instance', () => { + expect( + compile({ + type: 'Program', + body: [{ type: 'ContentStatement', value: 'Hello' }], + })() + ).toEqual('Hello'); + }); + + it('can pass through an empty string', () => { + expect(compile('')()).toEqual(''); + }); + + it('should not modify the options.data property(GH-1327)', () => { + // The `data` property is supposed to be a boolean, but in this test we want to ignore that + const options = { data: [{ a: 'foo' }, { a: 'bar' }] as unknown as boolean }; + compile('{{#each data}}{{@index}}:{{a}} {{/each}}', options)(); + expect(JSON.stringify(options, null, 2)).toEqual( + JSON.stringify({ data: [{ a: 'foo' }, { a: 'bar' }] }, null, 2) + ); + }); + + it('should not modify the options.knownHelpers property(GH-1327)', () => { + const options = { knownHelpers: {} }; + compile('{{#each data}}{{@index}}:{{a}} {{/each}}', options)(); + expect(JSON.stringify(options, null, 2)).toEqual( + JSON.stringify({ knownHelpers: {} }, null, 2) + ); + }); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.data.test.ts b/dev/lib/handlebars/src/spec/index.data.test.ts new file mode 100644 index 00000000..94d3b51c --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.data.test.ts @@ -0,0 +1,269 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('data', () => { + it('passing in data to a compiled function that expects data - works with helpers', () => { + expectTemplate('{{hello}}') + .withCompileOptions({ data: true }) + .withHelper('hello', function (this: any, options) { + return options.data.adjective + ' ' + this.noun; + }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .withInput({ noun: 'cat' }) + .toCompileTo('happy cat'); + }); + + it('data can be looked up via @foo', () => { + expectTemplate('{{@hello}}') + .withRuntimeOptions({ data: { hello: 'hello' } }) + .toCompileTo('hello'); + }); + + it('deep @foo triggers automatic top-level data', () => { + global.kbnHandlebarsEnv = Handlebars.create(); + const helpers = Handlebars.createFrame(kbnHandlebarsEnv!.helpers); + + helpers.let = function (options: HelperOptions) { + const frame = Handlebars.createFrame(options.data); + + for (const prop in options.hash) { + if (prop in options.hash) { + frame[prop] = options.hash[prop]; + } + } + return options.fn(this, { data: frame }); + }; + + expectTemplate( + '{{#let world="world"}}{{#if foo}}{{#if foo}}Hello {{@world}}{{/if}}{{/if}}{{/let}}' + ) + .withInput({ foo: true }) + .withHelpers(helpers) + .toCompileTo('Hello world'); + + global.kbnHandlebarsEnv = null; + }); + + it('parameter data can be looked up via @foo', () => { + expectTemplate('{{hello @world}}') + .withRuntimeOptions({ data: { world: 'world' } }) + .withHelper('hello', function (noun) { + return 'Hello ' + noun; + }) + .toCompileTo('Hello world'); + }); + + it('hash values can be looked up via @foo', () => { + expectTemplate('{{hello noun=@world}}') + .withRuntimeOptions({ data: { world: 'world' } }) + .withHelper('hello', function (options) { + return 'Hello ' + options.hash.noun; + }) + .toCompileTo('Hello world'); + }); + + it('nested parameter data can be looked up via @foo.bar', () => { + expectTemplate('{{hello @world.bar}}') + .withRuntimeOptions({ data: { world: { bar: 'world' } } }) + .withHelper('hello', function (noun) { + return 'Hello ' + noun; + }) + .toCompileTo('Hello world'); + }); + + it('nested parameter data does not fail with @world.bar', () => { + expectTemplate('{{hello @world.bar}}') + .withRuntimeOptions({ data: { foo: { bar: 'world' } } }) + .withHelper('hello', function (noun) { + return 'Hello ' + noun; + }) + .toCompileTo('Hello undefined'); + }); + + it('parameter data throws when using complex scope references', () => { + expectTemplate('{{#goodbyes}}{{text}} cruel {{@foo/../name}}! {{/goodbyes}}').toThrow(Error); + }); + + it('data can be functions', () => { + expectTemplate('{{@hello}}') + .withRuntimeOptions({ + data: { + hello() { + return 'hello'; + }, + }, + }) + .toCompileTo('hello'); + }); + + it('data can be functions with params', () => { + expectTemplate('{{@hello "hello"}}') + .withRuntimeOptions({ + data: { + hello(arg: any) { + return arg; + }, + }, + }) + .toCompileTo('hello'); + }); + + it('data is inherited downstream', () => { + expectTemplate( + '{{#let foo=1 bar=2}}{{#let foo=bar.baz}}{{@bar}}{{@foo}}{{/let}}{{@foo}}{{/let}}' + ) + .withInput({ bar: { baz: 'hello world' } }) + .withCompileOptions({ data: true }) + .withHelper('let', function (this: any, options) { + const frame = Handlebars.createFrame(options.data); + for (const prop in options.hash) { + if (prop in options.hash) { + frame[prop] = options.hash[prop]; + } + } + return options.fn(this, { data: frame }); + }) + .withRuntimeOptions({ data: {} }) + .toCompileTo('2hello world1'); + }); + + it('passing in data to a compiled function that expects data - works with helpers in partials', () => { + expectTemplate('{{>myPartial}}') + .withCompileOptions({ data: true }) + .withPartial('myPartial', '{{hello}}') + .withHelper('hello', function (this: any, options: HelperOptions) { + return options.data.adjective + ' ' + this.noun; + }) + .withInput({ noun: 'cat' }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .toCompileTo('happy cat'); + }); + + it('passing in data to a compiled function that expects data - works with helpers and parameters', () => { + expectTemplate('{{hello world}}') + .withCompileOptions({ data: true }) + .withHelper('hello', function (this: any, noun, options) { + return options.data.adjective + ' ' + noun + (this.exclaim ? '!' : ''); + }) + .withInput({ exclaim: true, world: 'world' }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .toCompileTo('happy world!'); + }); + + it('passing in data to a compiled function that expects data - works with block helpers', () => { + expectTemplate('{{#hello}}{{world}}{{/hello}}') + .withCompileOptions({ + data: true, + }) + .withHelper('hello', function (this: any, options) { + return options.fn(this); + }) + .withHelper('world', function (this: any, options) { + return options.data.adjective + ' world' + (this.exclaim ? '!' : ''); + }) + .withInput({ exclaim: true }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .toCompileTo('happy world!'); + }); + + it('passing in data to a compiled function that expects data - works with block helpers that use ..', () => { + expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}') + .withCompileOptions({ data: true }) + .withHelper('hello', function (options) { + return options.fn({ exclaim: '?' }); + }) + .withHelper('world', function (this: any, thing, options) { + return options.data.adjective + ' ' + thing + (this.exclaim || ''); + }) + .withInput({ exclaim: true, zomg: 'world' }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .toCompileTo('happy world?'); + }); + + it('passing in data to a compiled function that expects data - data is passed to with block helpers where children use ..', () => { + expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}') + .withCompileOptions({ data: true }) + .withHelper('hello', function (options) { + return options.data.accessData + ' ' + options.fn({ exclaim: '?' }); + }) + .withHelper('world', function (this: any, thing, options) { + return options.data.adjective + ' ' + thing + (this.exclaim || ''); + }) + .withInput({ exclaim: true, zomg: 'world' }) + .withRuntimeOptions({ data: { adjective: 'happy', accessData: '#win' } }) + .toCompileTo('#win happy world?'); + }); + + it('you can override inherited data when invoking a helper', () => { + expectTemplate('{{#hello}}{{world zomg}}{{/hello}}') + .withCompileOptions({ data: true }) + .withHelper('hello', function (options) { + return options.fn({ exclaim: '?', zomg: 'world' }, { data: { adjective: 'sad' } }); + }) + .withHelper('world', function (this: any, thing, options) { + return options.data.adjective + ' ' + thing + (this.exclaim || ''); + }) + .withInput({ exclaim: true, zomg: 'planet' }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .toCompileTo('sad world?'); + }); + + it('you can override inherited data when invoking a helper with depth', () => { + expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}') + .withCompileOptions({ data: true }) + .withHelper('hello', function (options) { + return options.fn({ exclaim: '?' }, { data: { adjective: 'sad' } }); + }) + .withHelper('world', function (this: any, thing, options) { + return options.data.adjective + ' ' + thing + (this.exclaim || ''); + }) + .withInput({ exclaim: true, zomg: 'world' }) + .withRuntimeOptions({ data: { adjective: 'happy' } }) + .toCompileTo('sad world?'); + }); + + describe('@root', () => { + it('the root context can be looked up via @root', () => { + expectTemplate('{{@root.foo}}') + .withInput({ foo: 'hello' }) + .withRuntimeOptions({ data: {} }) + .toCompileTo('hello'); + + expectTemplate('{{@root.foo}}').withInput({ foo: 'hello' }).toCompileTo('hello'); + }); + + it('passed root values take priority', () => { + expectTemplate('{{@root.foo}}') + .withInput({ foo: 'should not be used' }) + .withRuntimeOptions({ data: { root: { foo: 'hello' } } }) + .toCompileTo('hello'); + }); + }); + + describe('nesting', () => { + it('the root context can be looked up via @root', () => { + expectTemplate( + '{{#helper}}{{#helper}}{{@./depth}} {{@../depth}} {{@../../depth}}{{/helper}}{{/helper}}' + ) + .withInput({ foo: 'hello' }) + .withHelper('helper', function (this: any, options) { + const frame = Handlebars.createFrame(options.data); + frame.depth = options.data.depth + 1; + return options.fn(this, { data: frame }); + }) + .withRuntimeOptions({ + data: { + depth: 0, + }, + }) + .toCompileTo('2 1 0'); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.helpers.test.ts b/dev/lib/handlebars/src/spec/index.helpers.test.ts new file mode 100644 index 00000000..4cfa39bb --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.helpers.test.ts @@ -0,0 +1,958 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +beforeEach(() => { + global.kbnHandlebarsEnv = Handlebars.create(); +}); + +afterEach(() => { + global.kbnHandlebarsEnv = null; +}); + +describe('helpers', () => { + it('helper with complex lookup$', () => { + expectTemplate('{{#goodbyes}}{{{link ../prefix}}}{{/goodbyes}}') + .withInput({ + prefix: '/root', + goodbyes: [{ text: 'Goodbye', url: 'goodbye' }], + }) + .withHelper('link', function (this: any, prefix) { + return '' + this.text + ''; + }) + .toCompileTo('Goodbye'); + }); + + it('helper for raw block gets raw content', () => { + expectTemplate('{{{{raw}}}} {{test}} {{{{/raw}}}}') + .withInput({ test: 'hello' }) + .withHelper('raw', function (options: HelperOptions) { + return options.fn(); + }) + .toCompileTo(' {{test}} '); + }); + + it('helper for raw block gets parameters', () => { + expectTemplate('{{{{raw 1 2 3}}}} {{test}} {{{{/raw}}}}') + .withInput({ test: 'hello' }) + .withHelper('raw', function (a, b, c, options: HelperOptions) { + const ret = options.fn() + a + b + c; + return ret; + }) + .toCompileTo(' {{test}} 123'); + }); + + describe('raw block parsing (with identity helper-function)', () => { + function runWithIdentityHelper(template: string, expected: string) { + expectTemplate(template) + .withHelper('identity', function (options: HelperOptions) { + return options.fn(); + }) + .toCompileTo(expected); + } + + it('helper for nested raw block gets raw content', () => { + runWithIdentityHelper( + '{{{{identity}}}} {{{{b}}}} {{{{/b}}}} {{{{/identity}}}}', + ' {{{{b}}}} {{{{/b}}}} ' + ); + }); + + it('helper for nested raw block works with empty content', () => { + runWithIdentityHelper('{{{{identity}}}}{{{{/identity}}}}', ''); + }); + + it.skip('helper for nested raw block works if nested raw blocks are broken', () => { + // This test was introduced in 4.4.4, but it was not the actual problem that lead to the patch release + // The test is deactivated, because in 3.x this template cases an exception and it also does not work in 4.4.3 + // If anyone can make this template work without breaking everything else, then go for it, + // but for now, this is just a known bug, that will be documented. + runWithIdentityHelper( + '{{{{identity}}}} {{{{a}}}} {{{{ {{{{/ }}}} }}}} {{{{/identity}}}}', + ' {{{{a}}}} {{{{ {{{{/ }}}} }}}} ' + ); + }); + + it('helper for nested raw block closes after first matching close', () => { + runWithIdentityHelper( + '{{{{identity}}}}abc{{{{/identity}}}} {{{{identity}}}}abc{{{{/identity}}}}', + 'abc abc' + ); + }); + + it('helper for nested raw block throw exception when with missing closing braces', () => { + const string = '{{{{a}}}} {{{{/a'; + expectTemplate(string).toThrow(); + }); + }); + + it('helper block with identical context', () => { + expectTemplate('{{#goodbyes}}{{name}}{{/goodbyes}}') + .withInput({ name: 'Alan' }) + .withHelper('goodbyes', function (this: any, options: HelperOptions) { + let out = ''; + const byes = ['Goodbye', 'goodbye', 'GOODBYE']; + for (let i = 0, j = byes.length; i < j; i++) { + out += byes[i] + ' ' + options.fn(this) + '! '; + } + return out; + }) + .toCompileTo('Goodbye Alan! goodbye Alan! GOODBYE Alan! '); + }); + + it('helper block with complex lookup expression', () => { + expectTemplate('{{#goodbyes}}{{../name}}{{/goodbyes}}') + .withInput({ name: 'Alan' }) + .withHelper('goodbyes', function (options: HelperOptions) { + let out = ''; + const byes = ['Goodbye', 'goodbye', 'GOODBYE']; + for (let i = 0, j = byes.length; i < j; i++) { + out += byes[i] + ' ' + options.fn({}) + '! '; + } + return out; + }) + .toCompileTo('Goodbye Alan! goodbye Alan! GOODBYE Alan! '); + }); + + it('helper with complex lookup and nested template', () => { + expectTemplate('{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}') + .withInput({ + prefix: '/root', + goodbyes: [{ text: 'Goodbye', url: 'goodbye' }], + }) + .withHelper('link', function (this: any, prefix, options: HelperOptions) { + return '' + options.fn(this) + ''; + }) + .toCompileTo('Goodbye'); + }); + + it('helper with complex lookup and nested template in VM+Compiler', () => { + expectTemplate('{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}') + .withInput({ + prefix: '/root', + goodbyes: [{ text: 'Goodbye', url: 'goodbye' }], + }) + .withHelper('link', function (this: any, prefix, options: HelperOptions) { + return '' + options.fn(this) + ''; + }) + .toCompileTo('Goodbye'); + }); + + it('helper returning undefined value', () => { + expectTemplate(' {{nothere}}') + .withHelpers({ + nothere() {}, + }) + .toCompileTo(' '); + + expectTemplate(' {{#nothere}}{{/nothere}}') + .withHelpers({ + nothere() {}, + }) + .toCompileTo(' '); + }); + + it('block helper', () => { + expectTemplate('{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!') + .withInput({ world: 'world' }) + .withHelper('goodbyes', function (options: HelperOptions) { + return options.fn({ text: 'GOODBYE' }); + }) + .toCompileTo('GOODBYE! cruel world!'); + }); + + it('block helper staying in the same context', () => { + expectTemplate('{{#form}}

{{name}}

{{/form}}') + .withInput({ name: 'Yehuda' }) + .withHelper('form', function (this: any, options: HelperOptions) { + return '
' + options.fn(this) + '
'; + }) + .toCompileTo('

Yehuda

'); + }); + + it('block helper should have context in this', () => { + function link(this: any, options: HelperOptions) { + return '' + options.fn(this) + ''; + } + + expectTemplate('') + .withInput({ + people: [ + { name: 'Alan', id: 1 }, + { name: 'Yehuda', id: 2 }, + ], + }) + .withHelper('link', link) + .toCompileTo( + '' + ); + }); + + it('block helper for undefined value', () => { + expectTemplate("{{#empty}}shouldn't render{{/empty}}").toCompileTo(''); + }); + + it('block helper passing a new context', () => { + expectTemplate('{{#form yehuda}}

{{name}}

{{/form}}') + .withInput({ yehuda: { name: 'Yehuda' } }) + .withHelper('form', function (context, options: HelperOptions) { + return '
' + options.fn(context) + '
'; + }) + .toCompileTo('

Yehuda

'); + }); + + it('block helper passing a complex path context', () => { + expectTemplate('{{#form yehuda/cat}}

{{name}}

{{/form}}') + .withInput({ yehuda: { name: 'Yehuda', cat: { name: 'Harold' } } }) + .withHelper('form', function (context, options: HelperOptions) { + return '
' + options.fn(context) + '
'; + }) + .toCompileTo('

Harold

'); + }); + + it('nested block helpers', () => { + expectTemplate('{{#form yehuda}}

{{name}}

{{#link}}Hello{{/link}}{{/form}}') + .withInput({ + yehuda: { name: 'Yehuda' }, + }) + .withHelper('link', function (this: any, options: HelperOptions) { + return '' + options.fn(this) + ''; + }) + .withHelper('form', function (context, options: HelperOptions) { + return '
' + options.fn(context) + '
'; + }) + .toCompileTo('

Yehuda

Hello
'); + }); + + it('block helper inverted sections', () => { + const string = "{{#list people}}{{name}}{{^}}Nobody's here{{/list}}"; + function list(this: any, context: any, options: HelperOptions) { + if (context.length > 0) { + let out = ''; + return out; + } else { + return '

' + options.inverse(this) + '

'; + } + } + + // the meaning here may be kind of hard to catch, but list.not is always called, + // so we should see the output of both + expectTemplate(string) + .withInput({ people: [{ name: 'Alan' }, { name: 'Yehuda' }] }) + .withHelpers({ list }) + .toCompileTo(''); + + expectTemplate(string) + .withInput({ people: [] }) + .withHelpers({ list }) + .toCompileTo("

Nobody's here

"); + + expectTemplate('{{#list people}}Hello{{^}}{{message}}{{/list}}') + .withInput({ + people: [], + message: "Nobody's here", + }) + .withHelpers({ list }) + .toCompileTo('

Nobody's here

'); + }); + + it('pathed lambas with parameters', () => { + const hash = { + helper: () => 'winning', + }; + // @ts-expect-error + hash.hash = hash; + + const helpers = { + './helper': () => 'fail', + }; + + expectTemplate('{{./helper 1}}').withInput(hash).withHelpers(helpers).toCompileTo('winning'); + expectTemplate('{{hash/helper 1}}').withInput(hash).withHelpers(helpers).toCompileTo('winning'); + }); + + describe('helpers hash', () => { + it('providing a helpers hash', () => { + expectTemplate('Goodbye {{cruel}} {{world}}!') + .withInput({ cruel: 'cruel' }) + .withHelpers({ + world() { + return 'world'; + }, + }) + .toCompileTo('Goodbye cruel world!'); + + expectTemplate('Goodbye {{#iter}}{{cruel}} {{world}}{{/iter}}!') + .withInput({ iter: [{ cruel: 'cruel' }] }) + .withHelpers({ + world() { + return 'world'; + }, + }) + .toCompileTo('Goodbye cruel world!'); + }); + + it('in cases of conflict, helpers win', () => { + expectTemplate('{{{lookup}}}') + .withInput({ lookup: 'Explicit' }) + .withHelpers({ + lookup() { + return 'helpers'; + }, + }) + .toCompileTo('helpers'); + + expectTemplate('{{lookup}}') + .withInput({ lookup: 'Explicit' }) + .withHelpers({ + lookup() { + return 'helpers'; + }, + }) + .toCompileTo('helpers'); + }); + + it('the helpers hash is available is nested contexts', () => { + expectTemplate('{{#outer}}{{#inner}}{{helper}}{{/inner}}{{/outer}}') + .withInput({ outer: { inner: { unused: [] } } }) + .withHelpers({ + helper() { + return 'helper'; + }, + }) + .toCompileTo('helper'); + }); + + it('the helper hash should augment the global hash', () => { + kbnHandlebarsEnv!.registerHelper('test_helper', function () { + return 'found it!'; + }); + + expectTemplate('{{test_helper}} {{#if cruel}}Goodbye {{cruel}} {{world}}!{{/if}}') + .withInput({ cruel: 'cruel' }) + .withHelpers({ + world() { + return 'world!'; + }, + }) + .toCompileTo('found it! Goodbye cruel world!!'); + }); + }); + + describe('registration', () => { + it('unregisters', () => { + deleteAllKeys(kbnHandlebarsEnv!.helpers); + + kbnHandlebarsEnv!.registerHelper('foo', function () { + return 'fail'; + }); + expect(kbnHandlebarsEnv!.helpers.foo).toBeDefined(); + kbnHandlebarsEnv!.unregisterHelper('foo'); + expect(kbnHandlebarsEnv!.helpers.foo).toBeUndefined(); + }); + + it('allows multiple globals', () => { + const ifHelper = kbnHandlebarsEnv!.helpers.if; + deleteAllKeys(kbnHandlebarsEnv!.helpers); + + kbnHandlebarsEnv!.registerHelper({ + if: ifHelper, + world() { + return 'world!'; + }, + testHelper() { + return 'found it!'; + }, + }); + + expectTemplate('{{testHelper}} {{#if cruel}}Goodbye {{cruel}} {{world}}!{{/if}}') + .withInput({ cruel: 'cruel' }) + .toCompileTo('found it! Goodbye cruel world!!'); + }); + + it('fails with multiple and args', () => { + expect(() => { + kbnHandlebarsEnv!.registerHelper( + // @ts-expect-error TypeScript is complaining about the invalid input just as the thrown error + { + world() { + return 'world!'; + }, + testHelper() { + return 'found it!'; + }, + }, + {} + ); + }).toThrow('Arg not supported with multiple helpers'); + }); + }); + + it('decimal number literals work', () => { + expectTemplate('Message: {{hello -1.2 1.2}}') + .withHelper('hello', function (times, times2) { + if (typeof times !== 'number') { + times = 'NaN'; + } + if (typeof times2 !== 'number') { + times2 = 'NaN'; + } + return 'Hello ' + times + ' ' + times2 + ' times'; + }) + .toCompileTo('Message: Hello -1.2 1.2 times'); + }); + + it('negative number literals work', () => { + expectTemplate('Message: {{hello -12}}') + .withHelper('hello', function (times) { + if (typeof times !== 'number') { + times = 'NaN'; + } + return 'Hello ' + times + ' times'; + }) + .toCompileTo('Message: Hello -12 times'); + }); + + describe('String literal parameters', () => { + it('simple literals work', () => { + expectTemplate('Message: {{hello "world" 12 true false}}') + .withHelper('hello', function (param, times, bool1, bool2) { + if (typeof times !== 'number') { + times = 'NaN'; + } + if (typeof bool1 !== 'boolean') { + bool1 = 'NaB'; + } + if (typeof bool2 !== 'boolean') { + bool2 = 'NaB'; + } + return 'Hello ' + param + ' ' + times + ' times: ' + bool1 + ' ' + bool2; + }) + .toCompileTo('Message: Hello world 12 times: true false'); + }); + + it('using a quote in the middle of a parameter raises an error', () => { + expectTemplate('Message: {{hello wo"rld"}}').toThrow(Error); + }); + + it('escaping a String is possible', () => { + expectTemplate('Message: {{{hello "\\"world\\""}}}') + .withHelper('hello', function (param) { + return 'Hello ' + param; + }) + .toCompileTo('Message: Hello "world"'); + }); + + it("it works with ' marks", () => { + expectTemplate('Message: {{{hello "Alan\'s world"}}}') + .withHelper('hello', function (param) { + return 'Hello ' + param; + }) + .toCompileTo("Message: Hello Alan's world"); + }); + }); + + describe('multiple parameters', () => { + it('simple multi-params work', () => { + expectTemplate('Message: {{goodbye cruel world}}') + .withInput({ cruel: 'cruel', world: 'world' }) + .withHelper('goodbye', function (cruel, world) { + return 'Goodbye ' + cruel + ' ' + world; + }) + .toCompileTo('Message: Goodbye cruel world'); + }); + + it('block multi-params work', () => { + expectTemplate('Message: {{#goodbye cruel world}}{{greeting}} {{adj}} {{noun}}{{/goodbye}}') + .withInput({ cruel: 'cruel', world: 'world' }) + .withHelper('goodbye', function (cruel, world, options: HelperOptions) { + return options.fn({ greeting: 'Goodbye', adj: cruel, noun: world }); + }) + .toCompileTo('Message: Goodbye cruel world'); + }); + }); + + describe('hash', () => { + it('helpers can take an optional hash', () => { + expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" times=12}}') + .withHelper('goodbye', function (options: HelperOptions) { + return ( + 'GOODBYE ' + + options.hash.cruel + + ' ' + + options.hash.world + + ' ' + + options.hash.times + + ' TIMES' + ); + }) + .toCompileTo('GOODBYE CRUEL WORLD 12 TIMES'); + }); + + it('helpers can take an optional hash with booleans', () => { + function goodbye(options: HelperOptions) { + if (options.hash.print === true) { + return 'GOODBYE ' + options.hash.cruel + ' ' + options.hash.world; + } else if (options.hash.print === false) { + return 'NOT PRINTING'; + } else { + return 'THIS SHOULD NOT HAPPEN'; + } + } + + expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" print=true}}') + .withHelper('goodbye', goodbye) + .toCompileTo('GOODBYE CRUEL WORLD'); + + expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" print=false}}') + .withHelper('goodbye', goodbye) + .toCompileTo('NOT PRINTING'); + }); + + it('block helpers can take an optional hash', () => { + expectTemplate('{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}') + .withHelper('goodbye', function (this: any, options: HelperOptions) { + return ( + 'GOODBYE ' + + options.hash.cruel + + ' ' + + options.fn(this) + + ' ' + + options.hash.times + + ' TIMES' + ); + }) + .toCompileTo('GOODBYE CRUEL world 12 TIMES'); + }); + + it('block helpers can take an optional hash with single quoted stings', () => { + expectTemplate('{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}') + .withHelper('goodbye', function (this: any, options: HelperOptions) { + return ( + 'GOODBYE ' + + options.hash.cruel + + ' ' + + options.fn(this) + + ' ' + + options.hash.times + + ' TIMES' + ); + }) + .toCompileTo('GOODBYE CRUEL world 12 TIMES'); + }); + + it('block helpers can take an optional hash with booleans', () => { + function goodbye(this: any, options: HelperOptions) { + if (options.hash.print === true) { + return 'GOODBYE ' + options.hash.cruel + ' ' + options.fn(this); + } else if (options.hash.print === false) { + return 'NOT PRINTING'; + } else { + return 'THIS SHOULD NOT HAPPEN'; + } + } + + expectTemplate('{{#goodbye cruel="CRUEL" print=true}}world{{/goodbye}}') + .withHelper('goodbye', goodbye) + .toCompileTo('GOODBYE CRUEL world'); + + expectTemplate('{{#goodbye cruel="CRUEL" print=false}}world{{/goodbye}}') + .withHelper('goodbye', goodbye) + .toCompileTo('NOT PRINTING'); + }); + }); + + describe('helperMissing', () => { + it('if a context is not found, helperMissing is used', () => { + expectTemplate('{{hello}} {{link_to world}}').toThrow(/Missing helper: "link_to"/); + }); + + it('if a context is not found, custom helperMissing is used', () => { + expectTemplate('{{hello}} {{link_to world}}') + .withInput({ hello: 'Hello', world: 'world' }) + .withHelper('helperMissing', function (mesg, options: HelperOptions) { + if (options.name === 'link_to') { + return new Handlebars.SafeString('' + mesg + ''); + } + }) + .toCompileTo('Hello world'); + }); + + it('if a value is not found, custom helperMissing is used', () => { + expectTemplate('{{hello}} {{link_to}}') + .withInput({ hello: 'Hello', world: 'world' }) + .withHelper('helperMissing', function (options: HelperOptions) { + if (options.name === 'link_to') { + return new Handlebars.SafeString('winning'); + } + }) + .toCompileTo('Hello winning'); + }); + }); + + describe('knownHelpers', () => { + it('Known helper should render helper', () => { + expectTemplate('{{hello}}') + .withCompileOptions({ + knownHelpers: { hello: true }, + }) + .withHelper('hello', function () { + return 'foo'; + }) + .toCompileTo('foo'); + }); + + it('Unknown helper in knownHelpers only mode should be passed as undefined', () => { + expectTemplate('{{typeof hello}}') + .withCompileOptions({ + knownHelpers: { typeof: true }, + knownHelpersOnly: true, + }) + .withHelper('typeof', function (arg) { + return typeof arg; + }) + .withHelper('hello', function () { + return 'foo'; + }) + .toCompileTo('undefined'); + }); + + it('Builtin helpers available in knownHelpers only mode', () => { + expectTemplate('{{#unless foo}}bar{{/unless}}') + .withCompileOptions({ + knownHelpersOnly: true, + }) + .toCompileTo('bar'); + }); + + it('Field lookup works in knownHelpers only mode', () => { + expectTemplate('{{foo}}') + .withCompileOptions({ + knownHelpersOnly: true, + }) + .withInput({ foo: 'bar' }) + .toCompileTo('bar'); + }); + + it('Conditional blocks work in knownHelpers only mode', () => { + expectTemplate('{{#foo}}bar{{/foo}}') + .withCompileOptions({ + knownHelpersOnly: true, + }) + .withInput({ foo: 'baz' }) + .toCompileTo('bar'); + }); + + it('Invert blocks work in knownHelpers only mode', () => { + expectTemplate('{{^foo}}bar{{/foo}}') + .withCompileOptions({ + knownHelpersOnly: true, + }) + .withInput({ foo: false }) + .toCompileTo('bar'); + }); + + it('Functions are bound to the context in knownHelpers only mode', () => { + expectTemplate('{{foo}}') + .withCompileOptions({ + knownHelpersOnly: true, + }) + .withInput({ + foo() { + return this.bar; + }, + bar: 'bar', + }) + .toCompileTo('bar'); + }); + + it('Unknown helper call in knownHelpers only mode should throw', () => { + expectTemplate('{{typeof hello}}') + .withCompileOptions({ knownHelpersOnly: true }) + .toThrow(Error); + }); + }); + + describe('blockHelperMissing', () => { + it('lambdas are resolved by blockHelperMissing, not handlebars proper', () => { + expectTemplate('{{#truthy}}yep{{/truthy}}') + .withInput({ + truthy() { + return true; + }, + }) + .toCompileTo('yep'); + }); + + it('lambdas resolved by blockHelperMissing are bound to the context', () => { + expectTemplate('{{#truthy}}yep{{/truthy}}') + .withInput({ + truthy() { + return this.truthiness(); + }, + truthiness() { + return false; + }, + }) + .toCompileTo(''); + }); + }); + + describe('name field', () => { + const helpers = { + blockHelperMissing(...args: any[]) { + return 'missing: ' + args[args.length - 1].name; + }, + helperMissing(...args: any[]) { + return 'helper missing: ' + args[args.length - 1].name; + }, + helper(...args: any[]) { + return 'ran: ' + args[args.length - 1].name; + }, + }; + + it('should include in ambiguous mustache calls', () => { + expectTemplate('{{helper}}').withHelpers(helpers).toCompileTo('ran: helper'); + }); + + it('should include in helper mustache calls', () => { + expectTemplate('{{helper 1}}').withHelpers(helpers).toCompileTo('ran: helper'); + }); + + it('should include in ambiguous block calls', () => { + expectTemplate('{{#helper}}{{/helper}}').withHelpers(helpers).toCompileTo('ran: helper'); + }); + + it('should include in simple block calls', () => { + expectTemplate('{{#./helper}}{{/./helper}}') + .withHelpers(helpers) + .toCompileTo('missing: ./helper'); + }); + + it('should include in helper block calls', () => { + expectTemplate('{{#helper 1}}{{/helper}}').withHelpers(helpers).toCompileTo('ran: helper'); + }); + + it('should include in known helper calls', () => { + expectTemplate('{{helper}}') + .withCompileOptions({ + knownHelpers: { helper: true }, + knownHelpersOnly: true, + }) + .withHelpers(helpers) + .toCompileTo('ran: helper'); + }); + + it('should include full id', () => { + expectTemplate('{{#foo.helper}}{{/foo.helper}}') + .withInput({ foo: {} }) + .withHelpers(helpers) + .toCompileTo('missing: foo.helper'); + }); + + it('should include full id if a hash is passed', () => { + expectTemplate('{{#foo.helper bar=baz}}{{/foo.helper}}') + .withInput({ foo: {} }) + .withHelpers(helpers) + .toCompileTo('helper missing: foo.helper'); + }); + }); + + describe('name conflicts', () => { + it('helpers take precedence over same-named context properties', () => { + expectTemplate('{{goodbye}} {{cruel world}}') + .withHelper('goodbye', function (this: any) { + return this.goodbye.toUpperCase(); + }) + .withHelper('cruel', function (world) { + return 'cruel ' + world.toUpperCase(); + }) + .withInput({ + goodbye: 'goodbye', + world: 'world', + }) + .toCompileTo('GOODBYE cruel WORLD'); + }); + + it('helpers take precedence over same-named context properties$', () => { + expectTemplate('{{#goodbye}} {{cruel world}}{{/goodbye}}') + .withHelper('goodbye', function (this: any, options: HelperOptions) { + return this.goodbye.toUpperCase() + options.fn(this); + }) + .withHelper('cruel', function (world) { + return 'cruel ' + world.toUpperCase(); + }) + .withInput({ + goodbye: 'goodbye', + world: 'world', + }) + .toCompileTo('GOODBYE cruel WORLD'); + }); + + it('Scoped names take precedence over helpers', () => { + expectTemplate('{{this.goodbye}} {{cruel world}} {{cruel this.goodbye}}') + .withHelper('goodbye', function (this: any) { + return this.goodbye.toUpperCase(); + }) + .withHelper('cruel', function (world) { + return 'cruel ' + world.toUpperCase(); + }) + .withInput({ + goodbye: 'goodbye', + world: 'world', + }) + .toCompileTo('goodbye cruel WORLD cruel GOODBYE'); + }); + + it('Scoped names take precedence over block helpers', () => { + expectTemplate('{{#goodbye}} {{cruel world}}{{/goodbye}} {{this.goodbye}}') + .withHelper('goodbye', function (this: any, options: HelperOptions) { + return this.goodbye.toUpperCase() + options.fn(this); + }) + .withHelper('cruel', function (world) { + return 'cruel ' + world.toUpperCase(); + }) + .withInput({ + goodbye: 'goodbye', + world: 'world', + }) + .toCompileTo('GOODBYE cruel WORLD goodbye'); + }); + }); + + describe('block params', () => { + it('should take presedence over context values', () => { + expectTemplate('{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{value}}') + .withInput({ value: 'foo' }) + .withHelper('goodbyes', function (options: HelperOptions) { + expect(options.fn.blockParams).toEqual(1); + return options.fn({ value: 'bar' }, { blockParams: [1, 2] }); + }) + .toCompileTo('1foo'); + }); + + it('should take presedence over helper values', () => { + expectTemplate('{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{value}}') + .withHelper('value', function () { + return 'foo'; + }) + .withHelper('goodbyes', function (options: HelperOptions) { + expect(options.fn.blockParams).toEqual(1); + return options.fn({}, { blockParams: [1, 2] }); + }) + .toCompileTo('1foo'); + }); + + it('should not take presedence over pathed values', () => { + expectTemplate('{{#goodbyes as |value|}}{{./value}}{{/goodbyes}}{{value}}') + .withInput({ value: 'bar' }) + .withHelper('value', function () { + return 'foo'; + }) + .withHelper('goodbyes', function (this: any, options: HelperOptions) { + expect(options.fn.blockParams).toEqual(1); + return options.fn(this, { blockParams: [1, 2] }); + }) + .toCompileTo('barfoo'); + }); + + it('should take presednece over parent block params', () => { + let value: number; + expectTemplate( + '{{#goodbyes as |value|}}{{#goodbyes}}{{value}}{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{/goodbyes}}{{/goodbyes}}{{value}}', + { + beforeEach() { + value = 1; + }, + } + ) + .withInput({ value: 'foo' }) + .withHelper('goodbyes', function (options: HelperOptions) { + return options.fn( + { value: 'bar' }, + { + blockParams: options.fn.blockParams === 1 ? [value++, value++] : undefined, + } + ); + }) + .toCompileTo('13foo'); + }); + + it('should allow block params on chained helpers', () => { + expectTemplate('{{#if bar}}{{else goodbyes as |value|}}{{value}}{{/if}}{{value}}') + .withInput({ value: 'foo' }) + .withHelper('goodbyes', function (options: HelperOptions) { + expect(options.fn.blockParams).toEqual(1); + return options.fn({ value: 'bar' }, { blockParams: [1, 2] }); + }) + .toCompileTo('1foo'); + }); + }); + + describe('built-in helpers malformed arguments ', () => { + it('if helper - too few arguments', () => { + expectTemplate('{{#if}}{{/if}}').toThrow(/#if requires exactly one argument/); + }); + + it('if helper - too many arguments, string', () => { + expectTemplate('{{#if test "string"}}{{/if}}').toThrow(/#if requires exactly one argument/); + }); + + it('if helper - too many arguments, undefined', () => { + expectTemplate('{{#if test undefined}}{{/if}}').toThrow(/#if requires exactly one argument/); + }); + + it('if helper - too many arguments, null', () => { + expectTemplate('{{#if test null}}{{/if}}').toThrow(/#if requires exactly one argument/); + }); + + it('unless helper - too few arguments', () => { + expectTemplate('{{#unless}}{{/unless}}').toThrow(/#unless requires exactly one argument/); + }); + + it('unless helper - too many arguments', () => { + expectTemplate('{{#unless test null}}{{/unless}}').toThrow( + /#unless requires exactly one argument/ + ); + }); + + it('with helper - too few arguments', () => { + expectTemplate('{{#with}}{{/with}}').toThrow(/#with requires exactly one argument/); + }); + + it('with helper - too many arguments', () => { + expectTemplate('{{#with test "string"}}{{/with}}').toThrow( + /#with requires exactly one argument/ + ); + }); + }); + + describe('the lookupProperty-option', () => { + it('should be passed to custom helpers', () => { + expectTemplate('{{testHelper}}') + .withHelper('testHelper', function testHelper(this: any, options: HelperOptions) { + return options.lookupProperty(this, 'testProperty'); + }) + .withInput({ testProperty: 'abc' }) + .toCompileTo('abc'); + }); + }); +}); + +function deleteAllKeys(obj: { [key: string]: any }) { + for (const key of Object.keys(obj)) { + delete obj[key]; + } +} diff --git a/dev/lib/handlebars/src/spec/index.partials.test.ts b/dev/lib/handlebars/src/spec/index.partials.test.ts new file mode 100644 index 00000000..65930d06 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.partials.test.ts @@ -0,0 +1,591 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench'; + +describe('partials', () => { + it('basic partials', () => { + const string = 'Dudes: {{#dudes}}{{> dude}}{{/dudes}}'; + const partial = '{{name}} ({{url}}) '; + const hash = { + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }; + + expectTemplate(string) + .withInput(hash) + .withPartials({ dude: partial }) + .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + + expectTemplate(string) + .withInput(hash) + .withPartials({ dude: partial }) + .withRuntimeOptions({ data: false }) + .withCompileOptions({ data: false }) + .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + }); + + it('dynamic partials', () => { + const string = 'Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}'; + const partial = '{{name}} ({{url}}) '; + const hash = { + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }; + const helpers = { + partial: () => 'dude', + }; + + expectTemplate(string) + .withInput(hash) + .withHelpers(helpers) + .withPartials({ dude: partial }) + .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + + expectTemplate(string) + .withInput(hash) + .withHelpers(helpers) + .withPartials({ dude: partial }) + .withRuntimeOptions({ data: false }) + .withCompileOptions({ data: false }) + .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + }); + + it('failing dynamic partials', () => { + expectTemplate('Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withHelper('partial', () => 'missing') + .withPartial('dude', '{{name}} ({{url}}) ') + .toThrow('The partial missing could not be found'); // TODO: Is there a way we can test that the error is of type `Handlebars.Exception`? + }); + + it('partials with context', () => { + expectTemplate('Dudes: {{>dude dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartial('dude', '{{#this}}{{name}} ({{url}}) {{/this}}') + .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + }); + + it('partials with no context', () => { + const partial = '{{name}} ({{url}}) '; + const hash = { + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }; + + expectTemplate('Dudes: {{#dudes}}{{>dude}}{{/dudes}}') + .withInput(hash) + .withPartial('dude', partial) + .withCompileOptions({ explicitPartialContext: true }) + .toCompileTo('Dudes: () () '); + + expectTemplate('Dudes: {{#dudes}}{{>dude name="foo"}}{{/dudes}}') + .withInput(hash) + .withPartial('dude', partial) + .withCompileOptions({ explicitPartialContext: true }) + .toCompileTo('Dudes: foo () foo () '); + }); + + it('partials with string context', () => { + expectTemplate('Dudes: {{>dude "dudes"}}') + .withPartial('dude', '{{.}}') + .toCompileTo('Dudes: dudes'); + }); + + it('partials with undefined context', () => { + expectTemplate('Dudes: {{>dude dudes}}') + .withPartial('dude', '{{foo}} Empty') + .toCompileTo('Dudes: Empty'); + }); + + it('partials with duplicate parameters', () => { + expectTemplate('Dudes: {{>dude dudes foo bar=baz}}').toThrow( + 'Unsupported number of partial arguments: 2 - 1:7' + ); + }); + + it('partials with parameters', () => { + expectTemplate('Dudes: {{#dudes}}{{> dude others=..}}{{/dudes}}') + .withInput({ + foo: 'bar', + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartial('dude', '{{others.foo}}{{name}} ({{url}}) ') + .toCompileTo('Dudes: barYehuda (http://yehuda) barAlan (http://alan) '); + }); + + it('partial in a partial', () => { + expectTemplate('Dudes: {{#dudes}}{{>dude}}{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartials({ + dude: '{{name}} {{> url}} ', + url: '{{url}}', + }) + .toCompileTo( + 'Dudes: Yehuda http://yehuda Alan http://alan ' + ); + }); + + it('rendering undefined partial throws an exception', () => { + expectTemplate('{{> whatever}}').toThrow('The partial whatever could not be found'); + }); + + it('registering undefined partial throws an exception', () => { + global.kbnHandlebarsEnv = Handlebars.create(); + + expect(() => { + kbnHandlebarsEnv!.registerPartial('undefined_test', undefined as any); + }).toThrow('Attempting to register a partial called "undefined_test" as undefined'); + + global.kbnHandlebarsEnv = null; + }); + + it('rendering template partial in vm mode throws an exception', () => { + expectTemplate('{{> whatever}}').toThrow('The partial whatever could not be found'); + }); + + it('rendering function partial in vm mode', () => { + function partial(context: any) { + return context.name + ' (' + context.url + ') '; + } + expectTemplate('Dudes: {{#dudes}}{{> dude}}{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartial('dude', partial) + .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + }); + + it('GH-14: a partial preceding a selector', () => { + expectTemplate('Dudes: {{>dude}} {{anotherDude}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('dude', '{{name}}') + .toCompileTo('Dudes: Jeepers Creepers'); + }); + + it('Partials with slash paths', () => { + expectTemplate('Dudes: {{> shared/dude}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('shared/dude', '{{name}}') + .toCompileTo('Dudes: Jeepers'); + }); + + it('Partials with slash and point paths', () => { + expectTemplate('Dudes: {{> shared/dude.thing}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('shared/dude.thing', '{{name}}') + .toCompileTo('Dudes: Jeepers'); + }); + + it('Global Partials', () => { + global.kbnHandlebarsEnv = Handlebars.create(); + + kbnHandlebarsEnv!.registerPartial('globalTest', '{{anotherDude}}'); + + expectTemplate('Dudes: {{> shared/dude}} {{> globalTest}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('shared/dude', '{{name}}') + .toCompileTo('Dudes: Jeepers Creepers'); + + kbnHandlebarsEnv!.unregisterPartial('globalTest'); + expect(kbnHandlebarsEnv!.partials.globalTest).toBeUndefined(); + + global.kbnHandlebarsEnv = null; + }); + + it('Multiple partial registration', () => { + global.kbnHandlebarsEnv = Handlebars.create(); + + kbnHandlebarsEnv!.registerPartial({ + 'shared/dude': '{{name}}', + globalTest: '{{anotherDude}}', + }); + + expectTemplate('Dudes: {{> shared/dude}} {{> globalTest}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('notused', 'notused') // trick the test bench into running with partials enabled + .toCompileTo('Dudes: Jeepers Creepers'); + + global.kbnHandlebarsEnv = null; + }); + + it('Partials with integer path', () => { + expectTemplate('Dudes: {{> 404}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial(404, '{{name}}') + .toCompileTo('Dudes: Jeepers'); + }); + + it('Partials with complex path', () => { + expectTemplate('Dudes: {{> 404/asdf?.bar}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('404/asdf?.bar', '{{name}}') + .toCompileTo('Dudes: Jeepers'); + }); + + it('Partials with escaped', () => { + expectTemplate('Dudes: {{> [+404/asdf?.bar]}}') + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('+404/asdf?.bar', '{{name}}') + .toCompileTo('Dudes: Jeepers'); + }); + + it('Partials with string', () => { + expectTemplate("Dudes: {{> '+404/asdf?.bar'}}") + .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) + .withPartial('+404/asdf?.bar', '{{name}}') + .toCompileTo('Dudes: Jeepers'); + }); + + it('should handle empty partial', () => { + expectTemplate('Dudes: {{#dudes}}{{> dude}}{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartial('dude', '') + .toCompileTo('Dudes: '); + }); + + // Skipping test as this only makes sense when there's no `compile` function (i.e. runtime-only mode). + // We do not support that mode with `@kbn/handlebars`, so there's no need to test it + it.skip('throw on missing partial', () => { + const handlebars = Handlebars.create(); + (handlebars.compile as any) = undefined; + const template = handlebars.precompile('{{> dude}}'); + const render = handlebars.template(eval('(' + template + ')')); // eslint-disable-line no-eval + expect(() => { + render( + {}, + { + partials: { + dude: 'fail', + }, + } + ); + }).toThrow(/The partial dude could not be compiled/); + }); + + describe('partial blocks', () => { + it('should render partial block as default', () => { + expectTemplate('{{#> dude}}success{{/dude}}').toCompileTo('success'); + }); + + it('should execute default block with proper context', () => { + expectTemplate('{{#> dude context}}{{value}}{{/dude}}') + .withInput({ context: { value: 'success' } }) + .toCompileTo('success'); + }); + + it('should propagate block parameters to default block', () => { + expectTemplate('{{#with context as |me|}}{{#> dude}}{{me.value}}{{/dude}}{{/with}}') + .withInput({ context: { value: 'success' } }) + .toCompileTo('success'); + }); + + it('should not use partial block if partial exists', () => { + expectTemplate('{{#> dude}}fail{{/dude}}') + .withPartials({ dude: 'success' }) + .toCompileTo('success'); + }); + + it('should render block from partial', () => { + expectTemplate('{{#> dude}}success{{/dude}}') + .withPartials({ dude: '{{> @partial-block }}' }) + .toCompileTo('success'); + }); + + it('should be able to render the partial-block twice', () => { + expectTemplate('{{#> dude}}success{{/dude}}') + .withPartials({ dude: '{{> @partial-block }} {{> @partial-block }}' }) + .toCompileTo('success success'); + }); + + it('should render block from partial with context', () => { + expectTemplate('{{#> dude}}{{value}}{{/dude}}') + .withInput({ context: { value: 'success' } }) + .withPartials({ + dude: '{{#with context}}{{> @partial-block }}{{/with}}', + }) + .toCompileTo('success'); + }); + + it('should be able to access the @data frame from a partial-block', () => { + expectTemplate('{{#> dude}}in-block: {{@root/value}}{{/dude}}') + .withInput({ value: 'success' }) + .withPartials({ + dude: 'before-block: {{@root/value}} {{> @partial-block }}', + }) + .toCompileTo('before-block: success in-block: success'); + }); + + it('should allow the #each-helper to be used along with partial-blocks', () => { + expectTemplate('') + .withInput({ + value: ['a', 'b', 'c'], + }) + .withPartials({ + list: '{{#each .}}{{> @partial-block}}{{/each}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render block from partial with context (twice)', () => { + expectTemplate('{{#> dude}}{{value}}{{/dude}}') + .withInput({ context: { value: 'success' } }) + .withPartials({ + dude: '{{#with context}}{{> @partial-block }} {{> @partial-block }}{{/with}}', + }) + .toCompileTo('success success'); + }); + + it('should render block from partial with context [2]', () => { + expectTemplate('{{#> dude}}{{../context/value}}{{/dude}}') + .withInput({ context: { value: 'success' } }) + .withPartials({ + dude: '{{#with context}}{{> @partial-block }}{{/with}}', + }) + .toCompileTo('success'); + }); + + it('should render block from partial with block params', () => { + expectTemplate('{{#with context as |me|}}{{#> dude}}{{me.value}}{{/dude}}{{/with}}') + .withInput({ context: { value: 'success' } }) + .withPartials({ dude: '{{> @partial-block }}' }) + .toCompileTo('success'); + }); + + it('should render nested partial blocks', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}}{{/nested}}', + nested: '{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render nested partial blocks at different nesting levels', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}}{{/nested}}{{> @partial-block}}', + nested: '{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render nested partial blocks at different nesting levels (twice)', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}} {{> @partial-block}}{{/nested}}{{> @partial-block}}+{{> @partial-block}}', + nested: '{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render nested partial blocks (twice at each level)', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}} {{> @partial-block}}{{/nested}}', + nested: '{{> @partial-block}}{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + }); + + describe('inline partials', () => { + it('should define inline partials for template', () => { + expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}').toCompileTo( + 'success' + ); + }); + + it('should overwrite multiple partials in the same template', () => { + expectTemplate( + '{{#*inline "myPartial"}}fail{{/inline}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}' + ).toCompileTo('success'); + }); + + it('should define inline partials for block', () => { + expectTemplate( + '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}' + ).toCompileTo('success'); + + expectTemplate( + '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{/with}}{{> myPartial}}' + ).toThrow(/myPartial could not/); + }); + + it('should override global partials', () => { + expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}') + .withPartials({ + myPartial: () => 'fail', + }) + .toCompileTo('success'); + }); + + it('should override template partials', () => { + expectTemplate( + '{{#*inline "myPartial"}}fail{{/inline}}{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}' + ).toCompileTo('success'); + }); + + it('should override partials down the entire stack', () => { + expectTemplate( + '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{#with .}}{{#with .}}{{> myPartial}}{{/with}}{{/with}}{{/with}}' + ).toCompileTo('success'); + }); + + it('should define inline partials for partial call', () => { + expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> dude}}') + .withPartials({ dude: '{{> myPartial }}' }) + .toCompileTo('success'); + }); + + it('should define inline partials in partial block call', () => { + expectTemplate('{{#> dude}}{{#*inline "myPartial"}}success{{/inline}}{{/dude}}') + .withPartials({ dude: '{{> myPartial }}' }) + .toCompileTo('success'); + }); + + it('should render nested inline partials', () => { + expectTemplate( + '{{#*inline "outer"}}{{#>inner}}{{>@partial-block}}{{/inner}}{{/inline}}' + + '{{#*inline "inner"}}{{>@partial-block}}{{/inline}}' + + '{{#>outer}}{{value}}{{/outer}}' + ) + .withInput({ value: 'success' }) + .toCompileTo('success'); + }); + + it('should render nested inline partials with partial-blocks on different nesting levels', () => { + expectTemplate( + '{{#*inline "outer"}}{{#>inner}}{{>@partial-block}}{{/inner}}{{>@partial-block}}{{/inline}}' + + '{{#*inline "inner"}}{{>@partial-block}}{{/inline}}' + + '{{#>outer}}{{value}}{{/outer}}' + ) + .withInput({ value: 'success' }) + .toCompileTo('successsuccess'); + }); + + it('should render nested inline partials (twice at each level)', () => { + expectTemplate( + '{{#*inline "outer"}}{{#>inner}}{{>@partial-block}} {{>@partial-block}}{{/inner}}{{/inline}}' + + '{{#*inline "inner"}}{{>@partial-block}}{{>@partial-block}}{{/inline}}' + + '{{#>outer}}{{value}}{{/outer}}' + ) + .withInput({ value: 'success' }) + .toCompileTo( + 'success successsuccess success' + ); + }); + }); + + forEachCompileFunctionName((compileName) => { + it(`should pass compiler flags for ${compileName} function`, () => { + const env = Handlebars.create(); + env.registerPartial('partial', '{{foo}}'); + const compile = env[compileName].bind(env); + const template = compile('{{foo}} {{> partial}}', { noEscape: true }); + expect(template({ foo: '<' })).toEqual('< <'); + }); + }); + + describe('standalone partials', () => { + it('indented partials', () => { + expectTemplate('Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartial('dude', '{{name}}\n') + .toCompileTo('Dudes:\n Yehuda\n Alan\n'); + }); + + it('nested indented partials', () => { + expectTemplate('Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartials({ + dude: '{{name}}\n {{> url}}', + url: '{{url}}!\n', + }) + .toCompileTo('Dudes:\n Yehuda\n http://yehuda!\n Alan\n http://alan!\n'); + }); + + it('prevent nested indented partials', () => { + expectTemplate('Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}') + .withInput({ + dudes: [ + { name: 'Yehuda', url: 'http://yehuda' }, + { name: 'Alan', url: 'http://alan' }, + ], + }) + .withPartials({ + dude: '{{name}}\n {{> url}}', + url: '{{url}}!\n', + }) + .withCompileOptions({ preventIndent: true }) + .toCompileTo('Dudes:\n Yehuda\n http://yehuda!\n Alan\n http://alan!\n'); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.regressions.test.ts b/dev/lib/handlebars/src/spec/index.regressions.test.ts new file mode 100644 index 00000000..fc2065fe --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.regressions.test.ts @@ -0,0 +1,379 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench'; + +describe('Regressions', () => { + it('GH-94: Cannot read property of undefined', () => { + expectTemplate('{{#books}}{{title}}{{author.name}}{{/books}}') + .withInput({ + books: [ + { + title: 'The origin of species', + author: { + name: 'Charles Darwin', + }, + }, + { + title: 'Lazarillo de Tormes', + }, + ], + }) + .toCompileTo('The origin of speciesCharles DarwinLazarillo de Tormes'); + }); + + it("GH-150: Inverted sections print when they shouldn't", () => { + const string = '{{^set}}not set{{/set}} :: {{#set}}set{{/set}}'; + expectTemplate(string).toCompileTo('not set :: '); + expectTemplate(string).withInput({ set: undefined }).toCompileTo('not set :: '); + expectTemplate(string).withInput({ set: false }).toCompileTo('not set :: '); + expectTemplate(string).withInput({ set: true }).toCompileTo(' :: set'); + }); + + it('GH-158: Using array index twice, breaks the template', () => { + expectTemplate('{{arr.[0]}}, {{arr.[1]}}') + .withInput({ arr: [1, 2] }) + .toCompileTo('1, 2'); + }); + + it("bug reported by @fat where lambdas weren't being properly resolved", () => { + const string = + 'This is a slightly more complicated {{thing}}..\n' + + '{{! Just ignore this business. }}\n' + + 'Check this out:\n' + + '{{#hasThings}}\n' + + '.\n' + + '{{/hasThings}}\n' + + '{{^hasThings}}\n' + + '\n' + + 'Nothing to check out...\n' + + '{{/hasThings}}'; + + const data = { + thing() { + return 'blah'; + }, + things: [ + { className: 'one', word: '@fat' }, + { className: 'two', word: '@dhg' }, + { className: 'three', word: '@sayrer' }, + ], + hasThings() { + return true; + }, + }; + + const output = + 'This is a slightly more complicated blah..\n' + + 'Check this out:\n' + + '.\n'; + + expectTemplate(string).withInput(data).toCompileTo(output); + }); + + it('GH-408: Multiple loops fail', () => { + expectTemplate('{{#.}}{{name}}{{/.}}{{#.}}{{name}}{{/.}}{{#.}}{{name}}{{/.}}') + .withInput([ + { name: 'John Doe', location: { city: 'Chicago' } }, + { name: 'Jane Doe', location: { city: 'New York' } }, + ]) + .toCompileTo('John DoeJane DoeJohn DoeJane DoeJohn DoeJane Doe'); + }); + + it('GS-428: Nested if else rendering', () => { + const succeedingTemplate = + '{{#inverse}} {{#blk}} Unexpected {{/blk}} {{else}} {{#blk}} Expected {{/blk}} {{/inverse}}'; + const failingTemplate = + '{{#inverse}} {{#blk}} Unexpected {{/blk}} {{else}} {{#blk}} Expected {{/blk}} {{/inverse}}'; + + const helpers = { + blk(block: HelperOptions) { + return block.fn(''); + }, + inverse(block: HelperOptions) { + return block.inverse(''); + }, + }; + + expectTemplate(succeedingTemplate).withHelpers(helpers).toCompileTo(' Expected '); + expectTemplate(failingTemplate).withHelpers(helpers).toCompileTo(' Expected '); + }); + + it('GH-458: Scoped this identifier', () => { + expectTemplate('{{./foo}}').withInput({ foo: 'bar' }).toCompileTo('bar'); + }); + + it('GH-375: Unicode line terminators', () => { + expectTemplate('\u2028').toCompileTo('\u2028'); + }); + + it('GH-534: Object prototype aliases', () => { + /* eslint-disable no-extend-native */ + // @ts-expect-error + Object.prototype[0xd834] = true; + + expectTemplate('{{foo}}').withInput({ foo: 'bar' }).toCompileTo('bar'); + + // @ts-expect-error + delete Object.prototype[0xd834]; + /* eslint-enable no-extend-native */ + }); + + it('GH-437: Matching escaping', () => { + expectTemplate('{{{a}}').toThrow(/Parse error on/); + expectTemplate('{{a}}}').toThrow(/Parse error on/); + }); + + it('GH-676: Using array in escaping mustache fails', () => { + const data = { arr: [1, 2] }; + expectTemplate('{{arr}}').withInput(data).toCompileTo(data.arr.toString()); + }); + + it('Mustache man page', () => { + expectTemplate( + 'Hello {{name}}. You have just won ${{value}}!{{#in_ca}} Well, ${{taxed_value}}, after taxes.{{/in_ca}}' + ) + .withInput({ + name: 'Chris', + value: 10000, + taxed_value: 10000 - 10000 * 0.4, + in_ca: true, + }) + .toCompileTo('Hello Chris. You have just won $10000! Well, $6000, after taxes.'); + }); + + it('GH-731: zero context rendering', () => { + expectTemplate('{{#foo}} This is {{bar}} ~ {{/foo}}') + .withInput({ + foo: 0, + bar: 'OK', + }) + .toCompileTo(' This is ~ '); + }); + + it('GH-820: zero pathed rendering', () => { + expectTemplate('{{foo.bar}}').withInput({ foo: 0 }).toCompileTo(''); + }); + + it('GH-837: undefined values for helpers', () => { + expectTemplate('{{str bar.baz}}') + .withHelpers({ + str(value) { + return value + ''; + }, + }) + .toCompileTo('undefined'); + }); + + it('GH-926: Depths and de-dupe', () => { + expectTemplate( + '{{#if dater}}{{#each data}}{{../name}}{{/each}}{{else}}{{#each notData}}{{../name}}{{/each}}{{/if}}' + ) + .withInput({ + name: 'foo', + data: [1], + notData: [1], + }) + .toCompileTo('foo'); + }); + + it('GH-1021: Each empty string key', () => { + expectTemplate('{{#each data}}Key: {{@key}}\n{{/each}}') + .withInput({ + data: { + '': 'foo', + name: 'Chris', + value: 10000, + }, + }) + .toCompileTo('Key: \nKey: name\nKey: value\n'); + }); + + it('GH-1054: Should handle simple safe string responses', () => { + expectTemplate('{{#wrap}}{{>partial}}{{/wrap}}') + .withHelpers({ + wrap(options: HelperOptions) { + return new Handlebars.SafeString(options.fn()); + }, + }) + .withPartials({ + partial: '{{#wrap}}{{/wrap}}', + }) + .toCompileTo(''); + }); + + it('GH-1065: Sparse arrays', () => { + const array = []; + array[1] = 'foo'; + array[3] = 'bar'; + expectTemplate('{{#each array}}{{@index}}{{.}}{{/each}}') + .withInput({ array }) + .toCompileTo('1foo3bar'); + }); + + it('GH-1093: Undefined helper context', () => { + expectTemplate('{{#each obj}}{{{helper}}}{{.}}{{/each}}') + .withInput({ obj: { foo: undefined, bar: 'bat' } }) + .withHelpers({ + helper(this: any) { + // It's valid to execute a block against an undefined context, but + // helpers can not do so, so we expect to have an empty object here; + for (const name in this) { + if (Object.prototype.hasOwnProperty.call(this, name)) { + return 'found'; + } + } + // And to make IE happy, check for the known string as length is not enumerated. + return this === 'bat' ? 'found' : 'not'; + }, + }) + .toCompileTo('notfoundbat'); + }); + + it('should support multiple levels of inline partials', () => { + expectTemplate('{{#> layout}}{{#*inline "subcontent"}}subcontent{{/inline}}{{/layout}}') + .withPartials({ + doctype: 'doctype{{> content}}', + layout: '{{#> doctype}}{{#*inline "content"}}layout{{> subcontent}}{{/inline}}{{/doctype}}', + }) + .toCompileTo('doctypelayoutsubcontent'); + }); + + it('GH-1089: should support failover content in multiple levels of inline partials', () => { + expectTemplate('{{#> layout}}{{/layout}}') + .withPartials({ + doctype: 'doctype{{> content}}', + layout: + '{{#> doctype}}{{#*inline "content"}}layout{{#> subcontent}}subcontent{{/subcontent}}{{/inline}}{{/doctype}}', + }) + .toCompileTo('doctypelayoutsubcontent'); + }); + + it('GH-1099: should support greater than 3 nested levels of inline partials', () => { + expectTemplate('{{#> layout}}Outer{{/layout}}') + .withPartials({ + layout: '{{#> inner}}Inner{{/inner}}{{> @partial-block }}', + inner: '', + }) + .toCompileTo('Outer'); + }); + + it('GH-1135 : Context handling within each iteration', () => { + expectTemplate( + '{{#each array}}\n' + + ' 1. IF: {{#if true}}{{../name}}-{{../../name}}-{{../../../name}}{{/if}}\n' + + ' 2. MYIF: {{#myif true}}{{../name}}={{../../name}}={{../../../name}}{{/myif}}\n' + + '{{/each}}' + ) + .withInput({ array: [1], name: 'John' }) + .withHelpers({ + myif(conditional, options: HelperOptions) { + if (conditional) { + return options.fn(this); + } else { + return options.inverse(this); + } + }, + }) + .toCompileTo(' 1. IF: John--\n' + ' 2. MYIF: John==\n'); + }); + + it('GH-1186: Support block params for existing programs', () => { + expectTemplate( + '{{#*inline "test"}}{{> @partial-block }}{{/inline}}' + + '{{#>test }}{{#each listOne as |item|}}{{ item }}{{/each}}{{/test}}' + + '{{#>test }}{{#each listTwo as |item|}}{{ item }}{{/each}}{{/test}}' + ) + .withInput({ + listOne: ['a'], + listTwo: ['b'], + }) + .toCompileTo('ab'); + }); + + it('GH-1319: "unless" breaks when "each" value equals "null"', () => { + expectTemplate('{{#each list}}{{#unless ./prop}}parent={{../value}} {{/unless}}{{/each}}') + .withInput({ + value: 'parent', + list: [null, 'a'], + }) + .toCompileTo('parent=parent parent=parent '); + }); + + it('GH-1341: 4.0.7 release breaks {{#if @partial-block}} usage', () => { + expectTemplate('template {{>partial}} template') + .withPartials({ + partialWithBlock: '{{#if @partial-block}} block {{> @partial-block}} block {{/if}}', + partial: '{{#> partialWithBlock}} partial {{/partialWithBlock}}', + }) + .toCompileTo('template block partial block template'); + }); + + it('should allow hash with protected array names', () => { + expectTemplate('{{helpa length="foo"}}') + .withInput({ array: [1], name: 'John' }) + .withHelpers({ + helpa(options: HelperOptions) { + return options.hash.length; + }, + }) + .toCompileTo('foo'); + }); + + describe('GH-1598: Performance degradation for partials since v4.3.0', () => { + let newHandlebarsInstance: typeof Handlebars; + let spy: jest.SpyInstance; + beforeEach(() => { + newHandlebarsInstance = Handlebars.create(); + }); + afterEach(() => { + spy.mockRestore(); + }); + + forEachCompileFunctionName((compileName) => { + it(`should only compile global partials once when calling #${compileName}`, () => { + const compile = newHandlebarsInstance[compileName].bind(newHandlebarsInstance); + let calls; + switch (compileName) { + case 'compile': + spy = jest.spyOn(newHandlebarsInstance, 'template'); + calls = 3; + break; + case 'compileAST': + spy = jest.spyOn(newHandlebarsInstance, 'compileAST'); + calls = 2; + break; + } + newHandlebarsInstance.registerPartial({ + dude: 'I am a partial', + }); + const string = 'Dudes: {{> dude}} {{> dude}}'; + compile(string)(); // This should compile template + partial once + compile(string)(); // This should only compile template + expect(spy).toHaveBeenCalledTimes(calls); + spy.mockRestore(); + }); + }); + }); + + describe("GH-1639: TypeError: Cannot read property 'apply' of undefined\" when handlebars version > 4.6.0 (undocumented, deprecated usage)", () => { + it('should treat undefined helpers like non-existing helpers', () => { + expectTemplate('{{foo}}') + .withHelper('foo', undefined as any) + .withInput({ foo: 'bar' }) + .toCompileTo('bar'); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.security.test.ts b/dev/lib/handlebars/src/spec/index.security.test.ts new file mode 100644 index 00000000..878a0931 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.security.test.ts @@ -0,0 +1,132 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('security issues', () => { + describe('GH-1495: Prevent Remote Code Execution via constructor', () => { + it('should not allow constructors to be accessed', () => { + expectTemplate('{{lookup (lookup this "constructor") "name"}}').withInput({}).toCompileTo(''); + expectTemplate('{{constructor.name}}').withInput({}).toCompileTo(''); + }); + + it('GH-1603: should not allow constructors to be accessed (lookup via toString)', () => { + expectTemplate('{{lookup (lookup this (list "constructor")) "name"}}') + .withInput({}) + .withHelper('list', function (element) { + return [element]; + }) + .toCompileTo(''); + }); + + it('should allow the "constructor" property to be accessed if it is an "ownProperty"', () => { + expectTemplate('{{constructor.name}}') + .withInput({ constructor: { name: 'here we go' } }) + .toCompileTo('here we go'); + + expectTemplate('{{lookup (lookup this "constructor") "name"}}') + .withInput({ constructor: { name: 'here we go' } }) + .toCompileTo('here we go'); + }); + + it('should allow the "constructor" property to be accessed if it is an "own property"', () => { + expectTemplate('{{lookup (lookup this "constructor") "name"}}') + .withInput({ constructor: { name: 'here we go' } }) + .toCompileTo('here we go'); + }); + }); + + describe('GH-1558: Prevent explicit call of helperMissing-helpers', () => { + describe('without the option "allowExplicitCallOfHelperMissing"', () => { + it('should throw an exception when calling "{{helperMissing}}" ', () => { + expectTemplate('{{helperMissing}}').toThrow(Error); + }); + + it('should throw an exception when calling "{{#helperMissing}}{{/helperMissing}}" ', () => { + expectTemplate('{{#helperMissing}}{{/helperMissing}}').toThrow(Error); + }); + + it('should throw an exception when calling "{{blockHelperMissing "abc" .}}" ', () => { + const functionCalls = []; + expect(() => { + const template = Handlebars.compile('{{blockHelperMissing "abc" .}}'); + template({ + fn() { + functionCalls.push('called'); + }, + }); + }).toThrow(Error); + expect(functionCalls.length).toEqual(0); + }); + + it('should throw an exception when calling "{{#blockHelperMissing .}}{{/blockHelperMissing}}"', () => { + expectTemplate('{{#blockHelperMissing .}}{{/blockHelperMissing}}') + .withInput({ + fn() { + return 'functionInData'; + }, + }) + .toThrow(Error); + }); + }); + }); + + describe('GH-1563', () => { + it('should not allow to access constructor after overriding via __defineGetter__', () => { + // @ts-expect-error + if ({}.__defineGetter__ == null || {}.__lookupGetter__ == null) { + return; // Browser does not support this exploit anyway + } + expectTemplate( + '{{__defineGetter__ "undefined" valueOf }}' + + '{{#with __lookupGetter__ }}' + + '{{__defineGetter__ "propertyIsEnumerable" (this.bind (this.bind 1)) }}' + + '{{constructor.name}}' + + '{{/with}}' + ) + .withInput({}) + .toThrow(/Missing helper: "__defineGetter__"/); + }); + }); + + describe('GH-1595: dangerous properties', () => { + const templates = [ + '{{constructor}}', + '{{__defineGetter__}}', + '{{__defineSetter__}}', + '{{__lookupGetter__}}', + '{{__proto__}}', + '{{lookup this "constructor"}}', + '{{lookup this "__defineGetter__"}}', + '{{lookup this "__defineSetter__"}}', + '{{lookup this "__lookupGetter__"}}', + '{{lookup this "__proto__"}}', + ]; + + templates.forEach((template) => { + describe('access should be denied to ' + template, () => { + it('by default', () => { + expectTemplate(template).withInput({}).toCompileTo(''); + }); + }); + }); + }); + + describe('escapes template variables', () => { + it('in default mode', () => { + expectTemplate("{{'a\\b'}}").withCompileOptions().withInput({ 'a\\b': 'c' }).toCompileTo('c'); + }); + + it('in strict mode', () => { + expectTemplate("{{'a\\b'}}") + .withCompileOptions({ strict: true }) + .withInput({ 'a\\b': 'c' }) + .toCompileTo('c'); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.strict.test.ts b/dev/lib/handlebars/src/spec/index.strict.test.ts new file mode 100644 index 00000000..a8f294b9 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.strict.test.ts @@ -0,0 +1,164 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { expectTemplate } from '../__jest__/test_bench'; + +describe('strict', () => { + describe('strict mode', () => { + it('should error on missing property lookup', () => { + expectTemplate('{{hello}}') + .withCompileOptions({ strict: true }) + .toThrow(/"hello" not defined in/); + }); + + it('should error on missing child', () => { + expectTemplate('{{hello.bar}}') + .withCompileOptions({ strict: true }) + .withInput({ hello: { bar: 'foo' } }) + .toCompileTo('foo'); + + expectTemplate('{{hello.bar}}') + .withCompileOptions({ strict: true }) + .withInput({ hello: {} }) + .toThrow(/"bar" not defined in/); + }); + + it('should handle explicit undefined', () => { + expectTemplate('{{hello.bar}}') + .withCompileOptions({ strict: true }) + .withInput({ hello: { bar: undefined } }) + .toCompileTo(''); + }); + + it('should error on missing property lookup in known helpers mode', () => { + expectTemplate('{{hello}}') + .withCompileOptions({ + strict: true, + knownHelpersOnly: true, + }) + .toThrow(/"hello" not defined in/); + }); + + it('should error on missing context', () => { + expectTemplate('{{hello}}').withCompileOptions({ strict: true }).toThrow(Error); + }); + + it('should error on missing data lookup', () => { + const xt = expectTemplate('{{@hello}}').withCompileOptions({ + strict: true, + }); + + xt.toThrow(Error); + + xt.withRuntimeOptions({ data: { hello: 'foo' } }).toCompileTo('foo'); + }); + + it('should not run helperMissing for helper calls', () => { + expectTemplate('{{hello foo}}') + .withCompileOptions({ strict: true }) + .withInput({ foo: true }) + .toThrow(/"hello" not defined in/); + + expectTemplate('{{#hello foo}}{{/hello}}') + .withCompileOptions({ strict: true }) + .withInput({ foo: true }) + .toThrow(/"hello" not defined in/); + }); + + it('should throw on ambiguous blocks', () => { + expectTemplate('{{#hello}}{{/hello}}') + .withCompileOptions({ strict: true }) + .toThrow(/"hello" not defined in/); + + expectTemplate('{{^hello}}{{/hello}}') + .withCompileOptions({ strict: true }) + .toThrow(/"hello" not defined in/); + + expectTemplate('{{#hello.bar}}{{/hello.bar}}') + .withCompileOptions({ strict: true }) + .withInput({ hello: {} }) + .toThrow(/"bar" not defined in/); + }); + + it('should allow undefined parameters when passed to helpers', () => { + expectTemplate('{{#unless foo}}success{{/unless}}') + .withCompileOptions({ strict: true }) + .toCompileTo('success'); + }); + + it('should allow undefined hash when passed to helpers', () => { + expectTemplate('{{helper value=@foo}}') + .withCompileOptions({ + strict: true, + }) + .withHelpers({ + helper(options) { + expect('value' in options.hash).toEqual(true); + expect(options.hash.value).toBeUndefined(); + return 'success'; + }, + }) + .toCompileTo('success'); + }); + + it('should show error location on missing property lookup', () => { + expectTemplate('\n\n\n {{hello}}') + .withCompileOptions({ strict: true }) + .toThrow('"hello" not defined in [object Object] - 4:5'); + }); + + it('should error contains correct location properties on missing property lookup', () => { + try { + expectTemplate('\n\n\n {{hello}}') + .withCompileOptions({ strict: true }) + .toCompileTo('throw before asserting this'); + } catch (error) { + expect(error.lineNumber).toEqual(4); + expect(error.endLineNumber).toEqual(4); + expect(error.column).toEqual(5); + expect(error.endColumn).toEqual(10); + } + }); + }); + + describe('assume objects', () => { + it('should ignore missing property', () => { + expectTemplate('{{hello}}').withCompileOptions({ assumeObjects: true }).toCompileTo(''); + }); + + it('should ignore missing child', () => { + expectTemplate('{{hello.bar}}') + .withCompileOptions({ assumeObjects: true }) + .withInput({ hello: {} }) + .toCompileTo(''); + }); + + it('should error on missing object', () => { + expectTemplate('{{hello.bar}}').withCompileOptions({ assumeObjects: true }).toThrow(Error); + }); + + it('should error on missing context', () => { + expectTemplate('{{hello}}') + .withCompileOptions({ assumeObjects: true }) + .withInput(undefined) + .toThrow(Error); + }); + + it('should error on missing data lookup', () => { + expectTemplate('{{@hello.bar}}') + .withCompileOptions({ assumeObjects: true }) + .withInput(undefined) + .toThrow(Error); + }); + + it('should execute blockHelperMissing', () => { + expectTemplate('{{^hello}}foo{{/hello}}') + .withCompileOptions({ assumeObjects: true }) + .toCompileTo('foo'); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.subexpressions.test.ts b/dev/lib/handlebars/src/spec/index.subexpressions.test.ts new file mode 100644 index 00000000..4dee24b7 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.subexpressions.test.ts @@ -0,0 +1,214 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('subexpressions', () => { + it('arg-less helper', () => { + expectTemplate('{{foo (bar)}}!') + .withHelpers({ + foo(val) { + return val + val; + }, + bar() { + return 'LOL'; + }, + }) + .toCompileTo('LOLLOL!'); + }); + + it('helper w args', () => { + expectTemplate('{{blog (equal a b)}}') + .withInput({ bar: 'LOL' }) + .withHelpers({ + blog(val) { + return 'val is ' + val; + }, + equal(x, y) { + return x === y; + }, + }) + .toCompileTo('val is true'); + }); + + it('mixed paths and helpers', () => { + expectTemplate('{{blog baz.bat (equal a b) baz.bar}}') + .withInput({ bar: 'LOL', baz: { bat: 'foo!', bar: 'bar!' } }) + .withHelpers({ + blog(val, that, theOther) { + return 'val is ' + val + ', ' + that + ' and ' + theOther; + }, + equal(x, y) { + return x === y; + }, + }) + .toCompileTo('val is foo!, true and bar!'); + }); + + it('supports much nesting', () => { + expectTemplate('{{blog (equal (equal true true) true)}}') + .withInput({ bar: 'LOL' }) + .withHelpers({ + blog(val) { + return 'val is ' + val; + }, + equal(x, y) { + return x === y; + }, + }) + .toCompileTo('val is true'); + }); + + it('GH-800 : Complex subexpressions', () => { + const context = { a: 'a', b: 'b', c: { c: 'c' }, d: 'd', e: { e: 'e' } }; + const helpers = { + dash(a: any, b: any) { + return a + '-' + b; + }, + concat(a: any, b: any) { + return a + b; + }, + }; + + expectTemplate("{{dash 'abc' (concat a b)}}") + .withInput(context) + .withHelpers(helpers) + .toCompileTo('abc-ab'); + + expectTemplate('{{dash d (concat a b)}}') + .withInput(context) + .withHelpers(helpers) + .toCompileTo('d-ab'); + + expectTemplate('{{dash c.c (concat a b)}}') + .withInput(context) + .withHelpers(helpers) + .toCompileTo('c-ab'); + + expectTemplate('{{dash (concat a b) c.c}}') + .withInput(context) + .withHelpers(helpers) + .toCompileTo('ab-c'); + + expectTemplate('{{dash (concat a e.e) c.c}}') + .withInput(context) + .withHelpers(helpers) + .toCompileTo('ae-c'); + }); + + it('provides each nested helper invocation its own options hash', () => { + let lastOptions: HelperOptions; + const helpers = { + equal(x: any, y: any, options: HelperOptions) { + if (!options || options === lastOptions) { + throw new Error('options hash was reused'); + } + lastOptions = options; + return x === y; + }, + }; + expectTemplate('{{equal (equal true true) true}}').withHelpers(helpers).toCompileTo('true'); + }); + + it('with hashes', () => { + expectTemplate("{{blog (equal (equal true true) true fun='yes')}}") + .withInput({ bar: 'LOL' }) + .withHelpers({ + blog(val) { + return 'val is ' + val; + }, + equal(x, y) { + return x === y; + }, + }) + .toCompileTo('val is true'); + }); + + it('as hashes', () => { + expectTemplate("{{blog fun=(equal (blog fun=1) 'val is 1')}}") + .withHelpers({ + blog(options) { + return 'val is ' + options.hash.fun; + }, + equal(x, y) { + return x === y; + }, + }) + .toCompileTo('val is true'); + }); + + it('multiple subexpressions in a hash', () => { + expectTemplate('{{input aria-label=(t "Name") placeholder=(t "Example User")}}') + .withHelpers({ + input(options) { + const hash = options.hash; + const ariaLabel = Handlebars.Utils.escapeExpression(hash['aria-label']); + const placeholder = Handlebars.Utils.escapeExpression(hash.placeholder); + return new Handlebars.SafeString( + '' + ); + }, + t(defaultString) { + return new Handlebars.SafeString(defaultString); + }, + }) + .toCompileTo(''); + }); + + it('multiple subexpressions in a hash with context', () => { + expectTemplate('{{input aria-label=(t item.field) placeholder=(t item.placeholder)}}') + .withInput({ + item: { + field: 'Name', + placeholder: 'Example User', + }, + }) + .withHelpers({ + input(options) { + const hash = options.hash; + const ariaLabel = Handlebars.Utils.escapeExpression(hash['aria-label']); + const placeholder = Handlebars.Utils.escapeExpression(hash.placeholder); + return new Handlebars.SafeString( + '' + ); + }, + t(defaultString) { + return new Handlebars.SafeString(defaultString); + }, + }) + .toCompileTo(''); + }); + + it('subexpression functions on the context', () => { + expectTemplate('{{foo (bar)}}!') + .withInput({ + bar() { + return 'LOL'; + }, + }) + .withHelpers({ + foo(val) { + return val + val; + }, + }) + .toCompileTo('LOLLOL!'); + }); + + it("subexpressions can't just be property lookups", () => { + expectTemplate('{{foo (bar)}}!') + .withInput({ + bar: 'LOL', + }) + .withHelpers({ + foo(val) { + return val + val; + }, + }) + .toThrow(); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.utils.test.ts b/dev/lib/handlebars/src/spec/index.utils.test.ts new file mode 100644 index 00000000..6350bc7c --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.utils.test.ts @@ -0,0 +1,24 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('utils', function () { + describe('#SafeString', function () { + it('constructing a safestring from a string and checking its type', function () { + const safe = new Handlebars.SafeString('testing 1, 2, 3'); + expect(safe).toBeInstanceOf(Handlebars.SafeString); + expect(safe.toString()).toEqual('testing 1, 2, 3'); + }); + + it('it should not escape SafeString properties', function () { + const name = new Handlebars.SafeString('Sean O'Malley'); + expectTemplate('{{name}}').withInput({ name }).toCompileTo('Sean O'Malley'); + }); + }); +}); diff --git a/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts b/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts new file mode 100644 index 00000000..1f7cf019 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts @@ -0,0 +1,88 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { expectTemplate } from '../__jest__/test_bench'; + +describe('whitespace control', () => { + it('should strip whitespace around mustache calls', () => { + const hash = { foo: 'bar<' }; + expectTemplate(' {{~foo~}} ').withInput(hash).toCompileTo('bar<'); + expectTemplate(' {{~foo}} ').withInput(hash).toCompileTo('bar< '); + expectTemplate(' {{foo~}} ').withInput(hash).toCompileTo(' bar<'); + expectTemplate(' {{~&foo~}} ').withInput(hash).toCompileTo('bar<'); + expectTemplate(' {{~{foo}~}} ').withInput(hash).toCompileTo('bar<'); + expectTemplate('1\n{{foo~}} \n\n 23\n{{bar}}4').toCompileTo('1\n23\n4'); + }); + + describe('blocks', () => { + it('should strip whitespace around simple block calls', () => { + const hash = { foo: 'bar<' }; + + expectTemplate(' {{~#if foo~}} bar {{~/if~}} ').withInput(hash).toCompileTo('bar'); + expectTemplate(' {{#if foo~}} bar {{/if~}} ').withInput(hash).toCompileTo(' bar '); + expectTemplate(' {{~#if foo}} bar {{~/if}} ').withInput(hash).toCompileTo(' bar '); + expectTemplate(' {{#if foo}} bar {{/if}} ').withInput(hash).toCompileTo(' bar '); + + expectTemplate(' \n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\n ') + .withInput(hash) + .toCompileTo('bar'); + + expectTemplate(' a\n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\na ') + .withInput(hash) + .toCompileTo(' abara '); + }); + + it('should strip whitespace around inverse block calls', () => { + expectTemplate(' {{~^if foo~}} bar {{~/if~}} ').toCompileTo('bar'); + expectTemplate(' {{^if foo~}} bar {{/if~}} ').toCompileTo(' bar '); + expectTemplate(' {{~^if foo}} bar {{~/if}} ').toCompileTo(' bar '); + expectTemplate(' {{^if foo}} bar {{/if}} ').toCompileTo(' bar '); + expectTemplate(' \n\n{{~^if foo~}} \n\nbar \n\n{{~/if~}}\n\n ').toCompileTo('bar'); + }); + + it('should strip whitespace around complex block calls', () => { + const hash = { foo: 'bar<' }; + + expectTemplate('{{#if foo~}} bar {{~^~}} baz {{~/if}}').withInput(hash).toCompileTo('bar'); + expectTemplate('{{#if foo~}} bar {{^~}} baz {{/if}}').withInput(hash).toCompileTo('bar '); + expectTemplate('{{#if foo}} bar {{~^~}} baz {{~/if}}').withInput(hash).toCompileTo(' bar'); + expectTemplate('{{#if foo}} bar {{^~}} baz {{/if}}').withInput(hash).toCompileTo(' bar '); + expectTemplate('{{#if foo~}} bar {{~else~}} baz {{~/if}}').withInput(hash).toCompileTo('bar'); + + expectTemplate('\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n') + .withInput(hash) + .toCompileTo('bar'); + + expectTemplate('\n\n{{~#if foo~}} \n\n{{{foo}}} \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n') + .withInput(hash) + .toCompileTo('bar<'); + + expectTemplate('{{#if foo~}} bar {{~^~}} baz {{~/if}}').toCompileTo('baz'); + expectTemplate('{{#if foo}} bar {{~^~}} baz {{/if}}').toCompileTo('baz '); + expectTemplate('{{#if foo~}} bar {{~^}} baz {{~/if}}').toCompileTo(' baz'); + expectTemplate('{{#if foo~}} bar {{~^}} baz {{/if}}').toCompileTo(' baz '); + expectTemplate('{{#if foo~}} bar {{~else~}} baz {{~/if}}').toCompileTo('baz'); + expectTemplate('\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n').toCompileTo( + 'baz' + ); + }); + }); + + it('should strip whitespace around partials', () => { + expectTemplate('foo {{~> dude~}} ').withPartials({ dude: 'bar' }).toCompileTo('foobar'); + expectTemplate('foo {{> dude~}} ').withPartials({ dude: 'bar' }).toCompileTo('foo bar'); + expectTemplate('foo {{> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foo bar '); + expectTemplate('foo\n {{~> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foobar'); + expectTemplate('foo\n {{> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foo\n bar'); + }); + + it('should only strip whitespace once', () => { + expectTemplate(' {{~foo~}} {{foo}} {{foo}} ') + .withInput({ foo: 'bar' }) + .toCompileTo('barbar bar '); + }); +}); -- cgit v1.2.3