@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +105 -83
  3. package/src/agent.spec.js +312 -225
  4. package/src/ai_provider.js +2 -2
  5. package/src/ai_sdk_options.js +93 -39
  6. package/src/ai_sdk_options.spec.js +314 -154
  7. package/src/consts.js +6 -0
  8. package/src/generate.js +81 -0
  9. package/src/generate.spec.js +466 -0
  10. package/src/index.d.ts +222 -242
  11. package/src/index.js +3 -4
  12. package/src/prompt/content.js +70 -0
  13. package/src/prompt/content.spec.js +208 -0
  14. package/src/prompt/{escape.js → interpolations.js} +11 -18
  15. package/src/prompt/interpolations.spec.js +109 -0
  16. package/src/prompt/loader.full.spec.js +293 -0
  17. package/src/prompt/loader.js +45 -39
  18. package/src/prompt/loader.spec.js +186 -289
  19. package/src/prompt/markup/attributes.js +52 -0
  20. package/src/prompt/markup/attributes.spec.js +132 -0
  21. package/src/prompt/markup/nodes.js +71 -0
  22. package/src/prompt/markup/nodes.spec.js +333 -0
  23. package/src/prompt/markup/tokenizer.js +3 -0
  24. package/src/prompt/markup/tokenizer.spec.js +149 -0
  25. package/src/prompt/markup/tokens.js +26 -0
  26. package/src/prompt/markup/tokens.spec.js +250 -0
  27. package/src/prompt/validations.js +98 -67
  28. package/src/prompt/validations.spec.js +204 -47
  29. package/src/{prompt/load_content.js → utils/file.js} +12 -15
  30. package/src/utils/file.spec.js +89 -0
  31. package/src/utils/models.js +15 -0
  32. package/src/utils/models.spec.js +119 -0
  33. package/src/utils/skills.js +74 -0
  34. package/src/utils/skills.spec.js +168 -0
  35. package/src/utils/sources.js +48 -0
  36. package/src/utils/sources.spec.js +122 -0
  37. package/src/utils/stream.js +22 -0
  38. package/src/utils/stream.spec.js +55 -0
  39. package/src/utils/tools.js +47 -0
  40. package/src/utils/tools.spec.js +167 -0
  41. package/src/utils/wrap.js +148 -0
  42. package/src/utils/wrap.spec.js +359 -0
  43. package/src/validations.js +149 -27
  44. package/src/validations.spec.js +451 -53
  45. package/src/ai_model.js +0 -60
  46. package/src/ai_model.spec.js +0 -259
  47. package/src/ai_sdk.js +0 -92
  48. package/src/ai_sdk.spec.js +0 -564
  49. package/src/prompt/block_options.js +0 -58
  50. package/src/prompt/block_options.spec.js +0 -71
  51. package/src/prompt/blocks.js +0 -47
  52. package/src/prompt/blocks.spec.js +0 -63
  53. package/src/prompt/escape.spec.js +0 -159
  54. package/src/prompt/load_content.spec.js +0 -83
  55. package/src/prompt/loader_validation.spec.js +0 -128
  56. package/src/prompt/parser.js +0 -16
  57. package/src/prompt/parser.spec.js +0 -186
  58. package/src/prompt/prepare_text.js +0 -27
  59. package/src/prompt/prepare_text.spec.js +0 -141
  60. package/src/prompt/skill.js +0 -128
  61. package/src/prompt/skill.spec.js +0 -172
  62. package/src/utils/message.js +0 -3
  63. package/src/utils/message.spec.js +0 -29
  64. package/src/utils/response_wrappers.js +0 -100
  65. package/src/utils/response_wrappers.spec.js +0 -240
  66. package/src/utils/source_extraction.js +0 -53
  67. package/src/utils/source_extraction.spec.js +0 -194
  68. package/src/utils/trace.js +0 -19
  69. package/src/utils/trace.spec.js +0 -112
@@ -0,0 +1,70 @@
1
+ import { Role } from '../consts.js';
2
+ import { decode } from './interpolations.js';
3
+ import { FatalError } from '@outputai/core';
4
+ import { parseMarkup } from './markup/nodes.js';
5
+
6
+ /** Merge two objects 2-level deep. */
7
+ const mergeOptions = ( base = {}, extra = {} ) => {
8
+ const merged = { ...base };
9
+ for ( const [ namespace, options ] of Object.entries( extra ) ) {
10
+ merged[namespace] = { ...merged[namespace], ...options };
11
+ }
12
+ return merged;
13
+ };
14
+
15
+ /** Gets the options attribute from the message attributes */
16
+ const extractOptions = attributes => {
17
+ const { options, ...unsupportedAttributes } = attributes;
18
+ if ( Object.keys( unsupportedAttributes ).length > 0 ) {
19
+ throw new FatalError( 'Message has unsupported attributes. The only supported attribute is "options".' );
20
+ }
21
+ if ( options === true ) {
22
+ throw new FatalError( 'Message "options" attribute must have a value.' );
23
+ }
24
+ const rawOptions = options?.trim();
25
+ return rawOptions ? rawOptions.split( /\s+/ ) : null;
26
+ };
27
+
28
+ /** Merge down messageOptions selected from a list */
29
+ const resolveProviderOptions = ( options, messageOptions ) =>
30
+ options.reduce( ( result, option ) => {
31
+ if ( !messageOptions[option] ) {
32
+ throw new FatalError( `Message "options" attribute references unknown option "${option}" in \`config.messageOptions\`.` );
33
+ }
34
+ return mergeOptions( result, messageOptions[option] );
35
+ }, {} );
36
+
37
+ const promptRoleSet = new Set( [ Role.SYSTEM, Role.USER, Role.ASSISTANT ] );
38
+
39
+ /**
40
+ * Extract messages or instructions from prompt content. A leading role tag yields
41
+ * messages and null instructions; otherwise instructions are the decoded body.
42
+ */
43
+ export const parseContent = ( content, messageOptions ) => {
44
+ const nodes = parseMarkup( content );
45
+
46
+ if ( nodes.length === 0 ) {
47
+ return { messages: [], instructions: decode( content.trim() ) };
48
+ }
49
+
50
+ const invalidRole = nodes.find( node => !promptRoleSet.has( node.tagName ) );
51
+ if ( invalidRole ) {
52
+ throw new FatalError( `Message has invalid role "${invalidRole.tagName}".` );
53
+ }
54
+
55
+ return {
56
+ messages: nodes.map( node => {
57
+ const options = extractOptions( node.attributes );
58
+ if ( options && Object.keys( messageOptions ?? {} ).length === 0 ) {
59
+ throw new FatalError( 'Message has "options" attribute but `config.messageOptions` is empty.' );
60
+ }
61
+ const providerOptions = options ? resolveProviderOptions( options, messageOptions ) : null;
62
+ return {
63
+ role: node.tagName.toLowerCase(),
64
+ content: decode( node.content.trim() ),
65
+ ...( providerOptions && { providerOptions } )
66
+ };
67
+ } ),
68
+ instructions: null
69
+ };
70
+ };
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FatalError } from '@outputai/core';
3
+ import { Role } from '../consts.js';
4
+ import { parseContent } from './content.js';
5
+
6
+ const cached = { anthropic: { cacheControl: { type: 'ephemeral' } } };
7
+ const cachedLong = { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } };
8
+ const thinking = { anthropic: { thinking: { type: 'enabled' } } };
9
+ const openaiKey = { openai: { promptCacheKey: 'enrich-v1' } };
10
+
11
+ describe( 'parseContent', () => {
12
+ describe( 'messages and instructions', () => {
13
+ it( 'returns messages and null instructions when role tags are present', () => {
14
+ expect( parseContent( '<system>Hi</system>\n<user>Yo</user>' ) ).toEqual( {
15
+ messages: [
16
+ { role: Role.SYSTEM, content: 'Hi' },
17
+ { role: Role.USER, content: 'Yo' }
18
+ ],
19
+ instructions: null
20
+ } );
21
+ } );
22
+
23
+ it( 'treats content that starts with text as instructions', () => {
24
+ expect( parseContent( 'Note\n<user>Yo</user>\nTail' ) ).toEqual( {
25
+ messages: [],
26
+ instructions: 'Note\n<user>Yo</user>\nTail'
27
+ } );
28
+ } );
29
+
30
+ it( 'returns instructions and no messages when there are no role tags', () => {
31
+ expect( parseContent( 'Generate a cinematic poster.' ) ).toEqual( {
32
+ messages: [],
33
+ instructions: 'Generate a cinematic poster.'
34
+ } );
35
+ } );
36
+
37
+ it( 'trims instructions', () => {
38
+ expect( parseContent( ' poster ' ).instructions ).toBe( 'poster' );
39
+ } );
40
+
41
+ it( 'extracts every authored prompt role', () => {
42
+ const roles = [ Role.SYSTEM, Role.USER, Role.ASSISTANT ];
43
+ const body = roles.map( role => `<${role}>${role} body</${role}>` ).join( '\n' );
44
+
45
+ expect( parseContent( body ).messages.map( message => message.role ) ).toEqual( roles );
46
+ } );
47
+
48
+ it( 'omits providerOptions when the tag has no options attribute', () => {
49
+ expect( parseContent( '<user>Hi</user>' ).messages[0] ).toEqual( { role: Role.USER, content: 'Hi' } );
50
+ } );
51
+
52
+ it( 'trims each message body', () => {
53
+ expect( parseContent( '<user>\n Hello \n</user>' ).messages[0].content ).toBe( 'Hello' );
54
+ } );
55
+
56
+ it( 'decodes XML entities in message content', () => {
57
+ expect( parseContent( '<user>R&amp;D &lt; Speed</user>' ).messages[0].content ).toBe( 'R&D < Speed' );
58
+ } );
59
+
60
+ it( 'keeps a different nested role tag inside the top-level message', () => {
61
+ const body = [
62
+ '<user>',
63
+ 'Here:',
64
+ '```',
65
+ '<system>sys</system>',
66
+ '```',
67
+ '</user>'
68
+ ].join( '\n' );
69
+
70
+ expect( parseContent( body ) ).toEqual( {
71
+ messages: [ {
72
+ role: Role.USER,
73
+ content: 'Here:\n```\n<system>sys</system>\n```'
74
+ } ],
75
+ instructions: null
76
+ } );
77
+ } );
78
+
79
+ it( 'rejects a nested tag with the same role', () => {
80
+ expect( () => parseContent( '<user>ass<user>asasas</user></user>' ) )
81
+ .toThrow( 'Invalid child tag: <user> cannot appear inside another <user>. Escape it as "&lt;user&gt;".' );
82
+ } );
83
+
84
+ it( 'rejects an invalid top-level role', () => {
85
+ expect( () => parseContent( '<div><user>Hi</user></div>' ) )
86
+ .toThrow( 'Message has invalid role "div".' );
87
+ expect( () => parseContent( '<tool>Search result</tool>' ) )
88
+ .toThrow( 'Message has invalid role "tool".' );
89
+ } );
90
+
91
+ it( 'keeps closed inner markup in the message body', () => {
92
+ expect( parseContent( '<user>Compare <div></div> and <span></span> tags</user>' ).messages[0].content )
93
+ .toBe( 'Compare <div></div> and <span></span> tags' );
94
+ } );
95
+ } );
96
+
97
+ describe( 'options attribute', () => {
98
+ it( 'resolves a referenced messageOptions set into providerOptions', () => {
99
+ expect( parseContent( '<system options="cached">Docs</system>\n<user>Hello</user>', { cached } ) ).toEqual( {
100
+ messages: [
101
+ { role: Role.SYSTEM, content: 'Docs', providerOptions: cached },
102
+ { role: Role.USER, content: 'Hello' }
103
+ ],
104
+ instructions: null
105
+ } );
106
+ } );
107
+
108
+ it( 'does not put attributes on the message', () => {
109
+ const [ system ] = parseContent( '<system options="cached">Docs</system>', { cached } ).messages;
110
+ expect( system ).not.toHaveProperty( 'attributes' );
111
+ } );
112
+
113
+ it( 'lowercases the options attribute name', () => {
114
+ const [ system ] = parseContent( '<system OPTIONS="cached">Docs</system>', { cached } ).messages;
115
+ expect( system.providerOptions ).toEqual( cached );
116
+ } );
117
+
118
+ it( 'splits multiple set names on whitespace', () => {
119
+ const [ system ] = parseContent(
120
+ '<system options="cached openaiKey">Docs</system>',
121
+ { cached: cachedLong, openaiKey }
122
+ ).messages;
123
+
124
+ expect( system.providerOptions ).toEqual( { ...cachedLong, ...openaiKey } );
125
+ } );
126
+
127
+ it( 'trims extra whitespace around set names', () => {
128
+ const [ system ] = parseContent( '<system options=" cached ">Docs</system>', { cached } ).messages;
129
+ expect( system.providerOptions ).toEqual( cached );
130
+ } );
131
+
132
+ it( 'treats blank or whitespace-only options as no options', () => {
133
+ expect( parseContent( '<system options=" ">Docs</system>' ).messages ).toEqual( [
134
+ { role: Role.SYSTEM, content: 'Docs' }
135
+ ] );
136
+ expect( parseContent( '<system options="">Docs</system>' ).messages ).toEqual( [
137
+ { role: Role.SYSTEM, content: 'Docs' }
138
+ ] );
139
+ } );
140
+
141
+ it( 'throws when options has no value', () => {
142
+ expect( () => parseContent( '<system options>Docs</system>' ) )
143
+ .toThrow( new FatalError( 'Message "options" attribute must have a value.' ) );
144
+ } );
145
+
146
+ it( 'merges keys from two sets that share a provider namespace', () => {
147
+ const [ system ] = parseContent(
148
+ '<system options="cached thinking">Docs</system>',
149
+ { cached, thinking }
150
+ ).messages;
151
+
152
+ expect( system.providerOptions ).toEqual( {
153
+ anthropic: {
154
+ cacheControl: { type: 'ephemeral' },
155
+ thinking: { type: 'enabled' }
156
+ }
157
+ } );
158
+ } );
159
+
160
+ it( 'lets a later set win on overlapping keys in the same namespace', () => {
161
+ const [ system ] = parseContent(
162
+ '<system options="short long">Docs</system>',
163
+ { short: cached, long: cachedLong }
164
+ ).messages;
165
+
166
+ expect( system.providerOptions ).toEqual( cachedLong );
167
+ } );
168
+
169
+ it( 'replaces nested objects instead of deep-merging them', () => {
170
+ const [ system ] = parseContent(
171
+ '<system options="withTtl noTtl">Docs</system>',
172
+ { withTtl: cachedLong, noTtl: cached }
173
+ ).messages;
174
+
175
+ expect( system.providerOptions.anthropic.cacheControl ).toEqual( { type: 'ephemeral' } );
176
+ } );
177
+
178
+ it( 'applies the same set twice without changing the result', () => {
179
+ const once = parseContent( '<system options="cached">Docs</system>', { cached } );
180
+ const twice = parseContent( '<system options="cached cached">Docs</system>', { cached } );
181
+
182
+ expect( twice.messages[0].providerOptions ).toEqual( once.messages[0].providerOptions );
183
+ } );
184
+
185
+ it( 'throws when the tag has an unsupported attribute', () => {
186
+ expect( () => parseContent( '<system options="cached" ttl="1h">Docs</system>', { cached } ) )
187
+ .toThrow( FatalError );
188
+ expect( () => parseContent( '<system pinned>Docs</system>' ) ).toThrow( /unsupported attributes/ );
189
+ expect( () => parseContent( '<user data="x">Hi</user>' ) ).toThrow( /unsupported attributes/ );
190
+ } );
191
+
192
+ it( 'throws when options is set but messageOptions is missing or empty', () => {
193
+ expect( () => parseContent( '<system options="cached">Docs</system>' ) )
194
+ .toThrow( /config.messageOptions/ );
195
+ expect( () => parseContent( '<system options="cached">Docs</system>', {} ) )
196
+ .toThrow( /config.messageOptions/ );
197
+ } );
198
+
199
+ it( 'throws when a set name is not in messageOptions', () => {
200
+ expect( () => parseContent( '<user options="missing">Hello</user>', { cached } ) )
201
+ .toThrow( /unknown option "missing"/ );
202
+ } );
203
+
204
+ it( 'does not throw on missing messageOptions when no tag uses options', () => {
205
+ expect( () => parseContent( '<user>Hello</user>' ) ).not.toThrow();
206
+ } );
207
+ } );
208
+ } );
@@ -1,20 +1,14 @@
1
- import { encodeXML, decodeXML } from 'entities';
2
1
  import { Objects } from '@outputai/core/sdk/helpers';
2
+ import { encodeXML, decodeXML } from 'entities';
3
3
 
4
- const VAR_SAFE_FILTER = '__var_safe';
4
+ export const interpolationFilterToken = '__var_safe';
5
5
 
6
6
  /**
7
7
  * XML-escapes a value.
8
8
  * @param {unknown} value - Any value to escape
9
9
  * @returns {string} Escaped value or '' if original value was null/undefined
10
10
  */
11
- export const encodeFilter = value => [ null, undefined ].includes( value ) ? '' : encodeXML( String( value ) );
12
-
13
- /**
14
- * Sets up the encoder filter on a Liquid instance.
15
- * @param {object} liquid - LiquidJS instance
16
- */
17
- export const setupLiquidEncodeFilter = liquid => liquid.registerFilter( VAR_SAFE_FILTER, encodeFilter );
11
+ export const encode = value => [ null, undefined ].includes( value ) ? '' : encodeXML( String( value ) );
18
12
 
19
13
  /**
20
14
  * Matches {% raw %}...{% endraw %} or {{ ... }} tags
@@ -22,10 +16,9 @@ export const setupLiquidEncodeFilter = liquid => liquid.registerFilter( VAR_SAFE
22
16
  const VAR_OR_RAW = /(\{%\s*raw\s*%\}[\s\S]*?\{%\s*endraw\s*%\})|\{\{\s*([\s\S]+?)\s*\}\}/g;
23
17
 
24
18
  /**
25
- * Escapes Liquid templates so rendered variable content cannot be parsed as message blocks.
19
+ * Appends `| __var_safe` filter to every `{{ ... }}` expression.
26
20
  *
27
- * Appends `| __var_safe` to every `{{ ... }}` expression so variable output is XML-escaped
28
- * by the filter registered on Liquid before parsePrompt tokenizes message blocks.
21
+ * The filter XML-escape the interpolated content so it cannot be parsed as new message blocks.
29
22
  *
30
23
  * Without this, a variable whose value contains `<system>` or `</user>` would inject extra
31
24
  * message blocks.
@@ -36,16 +29,16 @@ const VAR_OR_RAW = /(\{%\s*raw\s*%\}[\s\S]*?\{%\s*endraw\s*%\})|\{\{\s*([\s\S]+?
36
29
  * Note: The `g` flag advances past any matched raw regions, so `{{ ... }}` inside them are
37
30
  * preserved as well.
38
31
  *
39
- * @param {string} raw - Raw string value
40
- * @returns {string} Escaped string
32
+ * @param {string} raw - Raw template string
33
+ * @returns {string} Template with `| __var_safe` appended to each interpolation
41
34
  */
42
- export const escape = raw =>
35
+ export const pipeInterpolations = raw =>
43
36
  raw.replace( VAR_OR_RAW, ( _, rawRegion, expressionContent ) =>
44
- rawRegion === undefined ? `{{ ${expressionContent.trim()} | ${VAR_SAFE_FILTER} }}` : rawRegion
37
+ rawRegion === undefined ? `{{ ${expressionContent.trim()} | ${interpolationFilterToken} }}` : rawRegion
45
38
  );
46
39
 
47
40
  /**
48
- * Recursively XML-decodes a value.
41
+ * Recursive decode a XML-encoded value.
49
42
  * @param {unknown} value - Value to decode
50
43
  * @returns {unknown} Decoded value
51
44
  */
@@ -58,7 +51,7 @@ export const decode = value => {
58
51
  }
59
52
  if ( Objects.isPlainObject( value ) ) {
60
53
  return Object.fromEntries(
61
- Object.entries( value ).map( ( [ k, v ] ) => [ k, decode( v ) ] )
54
+ Object.entries( value ).map( ( [ key, item ] ) => [ key, decode( item ) ] )
62
55
  );
63
56
  }
64
57
  return value;
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { decode, encode, interpolationFilterToken, pipeInterpolations } from './interpolations.js';
3
+
4
+ describe( 'encode', () => {
5
+ it( 'encodes XML special characters', () => {
6
+ expect( encode( '<a & b>' ) ).toBe( '&lt;a &amp; b&gt;' );
7
+ } );
8
+
9
+ it( 'encodes a tag-shaped substring so extractMessages cannot match it', () => {
10
+ expect( encode( '<system>x</system>' ) ).toBe( '&lt;system&gt;x&lt;/system&gt;' );
11
+ } );
12
+
13
+ it( 'returns an empty string for null and undefined', () => {
14
+ expect( encode( null ) ).toBe( '' );
15
+ expect( encode( undefined ) ).toBe( '' );
16
+ } );
17
+
18
+ it( 'coerces numbers and booleans to string', () => {
19
+ expect( encode( 42 ) ).toBe( '42' );
20
+ expect( encode( true ) ).toBe( 'true' );
21
+ } );
22
+
23
+ it( 'passes empty and plain strings through unchanged', () => {
24
+ expect( encode( '' ) ).toBe( '' );
25
+ expect( encode( 'hello world' ) ).toBe( 'hello world' );
26
+ } );
27
+ } );
28
+
29
+ describe( 'pipeInterpolations', () => {
30
+ const piped = expr => `{{ ${expr} | ${interpolationFilterToken} }}`;
31
+
32
+ it( 'appends the safety filter to a single interpolation', () => {
33
+ expect( pipeInterpolations( '{{ name }}' ) ).toBe( piped( 'name' ) );
34
+ } );
35
+
36
+ it( 'appends the safety filter to every interpolation', () => {
37
+ expect( pipeInterpolations( '{{ a }} and {{ b }}' ) ).toBe(
38
+ `${piped( 'a' )} and ${piped( 'b' )}`
39
+ );
40
+ } );
41
+
42
+ it( 'appends the safety filter last in an existing filter chain', () => {
43
+ expect( pipeInterpolations( '{{ x | upcase }}' ) ).toBe( piped( 'x | upcase' ) );
44
+ expect( pipeInterpolations( '{{ x | a | b }}' ) ).toBe( piped( 'x | a | b' ) );
45
+ } );
46
+
47
+ it( 'handles dotted property paths', () => {
48
+ expect( pipeInterpolations( '{{ obj.field }}' ) ).toBe( piped( 'obj.field' ) );
49
+ } );
50
+
51
+ it( 'preserves a {% raw %} block even when it contains {{ ... }}', () => {
52
+ const input = '{% raw %}{{ literal }}{% endraw %}';
53
+ expect( pipeInterpolations( input ) ).toBe( input );
54
+ } );
55
+
56
+ it( 'rewrites interpolations outside a raw block while preserving the raw block', () => {
57
+ expect( pipeInterpolations( '{{ a }}{% raw %}{{ b }}{% endraw %}{{ c }}' ) ).toBe(
58
+ `${piped( 'a' )}{% raw %}{{ b }}{% endraw %}${piped( 'c' )}`
59
+ );
60
+ } );
61
+
62
+ it( 'leaves control tags untouched and still pipes interpolations inside them', () => {
63
+ expect( pipeInterpolations( '{% if cond %}{{ x }}{% endif %}' ) ).toBe(
64
+ `{% if cond %}${piped( 'x' )}{% endif %}`
65
+ );
66
+ expect( pipeInterpolations( '{% for x in xs %}{{ x }}{% endfor %}' ) ).toBe(
67
+ `{% for x in xs %}${piped( 'x' )}{% endfor %}`
68
+ );
69
+ } );
70
+
71
+ it( 'normalizes interior whitespace', () => {
72
+ expect( pipeInterpolations( '{{x}}' ) ).toBe( piped( 'x' ) );
73
+ expect( pipeInterpolations( '{{ x }}' ) ).toBe( piped( 'x' ) );
74
+ } );
75
+
76
+ it( 'returns the input unchanged when there are no interpolations', () => {
77
+ expect( pipeInterpolations( '<user>plain text</user>' ) ).toBe( '<user>plain text</user>' );
78
+ expect( pipeInterpolations( '' ) ).toBe( '' );
79
+ } );
80
+ } );
81
+
82
+ describe( 'decode', () => {
83
+ it( 'decodes XML entities in a string', () => {
84
+ expect( decode( 'R&amp;D &lt; Speed &gt; &quot;Limits&quot;' ) ).toBe( 'R&D < Speed > "Limits"' );
85
+ } );
86
+
87
+ it( 'decodes XML entities recursively in arrays and plain objects', () => {
88
+ expect( decode( {
89
+ label: 'R&amp;D',
90
+ values: [ 'A &lt; B' ],
91
+ nested: {
92
+ title: '&quot;Race&quot;'
93
+ }
94
+ } ) ).toEqual( {
95
+ label: 'R&D',
96
+ values: [ 'A < B' ],
97
+ nested: {
98
+ title: '"Race"'
99
+ }
100
+ } );
101
+ } );
102
+
103
+ it( 'returns non-string scalar values unchanged', () => {
104
+ expect( decode( null ) ).toBeNull();
105
+ expect( decode( undefined ) ).toBeUndefined();
106
+ expect( decode( 42 ) ).toBe( 42 );
107
+ expect( decode( true ) ).toBe( true );
108
+ } );
109
+ } );