configorama 0.8.0 → 0.9.3
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.
- package/README.md +38 -3
- package/package.json +1 -1
- package/src/main.js +53 -21
- package/src/parsers/yaml.js +4 -4
- package/src/parsers/yaml.test.js +52 -0
- package/src/resolvers/valueFromFile.js +15 -2
- package/src/utils/PromiseTracker.js +54 -0
- package/src/utils/encoders/unknown-values.js +1 -1
- package/src/utils/encoders/unknown-values.test.js +146 -0
- package/src/utils/lodash.js +5 -4
- package/src/utils/lodash.test.js +172 -0
- package/src/utils/parsing/cloudformationSchema.js +24 -2
- package/src/utils/parsing/cloudformationSchema.test.js +236 -0
- package/src/utils/parsing/mergeByKeys.js +9 -8
- package/src/utils/parsing/mergeByKeys.test.js +189 -0
- package/src/utils/parsing/parse.js +5 -2
- package/src/utils/paths/getFullFilePath.js +2 -2
- package/src/utils/paths/getFullFilePath.test.js +152 -0
- package/src/utils/regex/index.js +65 -1
- package/src/utils/regex/index.test.js +195 -0
- package/src/utils/strings/formatFunctionArgs.js +4 -0
- package/src/utils/strings/splitCsv.js +46 -19
- package/types/src/main.d.ts.map +1 -1
- package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
- package/types/src/utils/PromiseTracker.d.ts +4 -0
- package/types/src/utils/PromiseTracker.d.ts.map +1 -1
- package/types/src/utils/lodash.d.ts.map +1 -1
- package/types/src/utils/parsing/mergeByKeys.d.ts.map +1 -1
- package/types/src/utils/parsing/parse.d.ts.map +1 -1
- package/types/src/utils/regex/index.d.ts +15 -1
- package/types/src/utils/regex/index.d.ts.map +1 -1
- package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
- package/types/src/utils/strings/splitCsv.d.ts +1 -1
- package/types/src/utils/strings/splitCsv.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -870,14 +870,49 @@ const config = await configorama(configFile, {
|
|
|
870
870
|
allowUnresolvedVariables: ['param', 'file'], // only these pass through
|
|
871
871
|
options: { stage: 'prod' }
|
|
872
872
|
})
|
|
873
|
-
//
|
|
874
|
-
//
|
|
873
|
+
// Input: { paramKey: '${param:x}', fileKey: '${file(missing.yml)}' }
|
|
874
|
+
// Output: { paramKey: '${param:x}', fileKey: '${file(missing.yml)}' }
|
|
875
|
+
|
|
876
|
+
// Allow only SPECIFIC types to be unresolved
|
|
877
|
+
const config = await configorama(configFile, {
|
|
878
|
+
allowUnresolvedVariables: ['param', 'file'], // only these pass through
|
|
879
|
+
options: { stage: 'prod' }
|
|
880
|
+
})
|
|
881
|
+
// Input: { key: '${env:MISSING_VAR}', paramKey: '${param:x}', fileKey: '${file(missing.yml)}' }
|
|
882
|
+
// Unresolved ${param:x} and ${file(missing.yml)} pass through but
|
|
883
|
+
// Output error thrown because ${env:MISSING_VAR} throws an error
|
|
875
884
|
```
|
|
876
885
|
|
|
877
|
-
This is useful for multi-stage resolution (e.g., Serverless Dashboard resolves params after local resolution).
|
|
886
|
+
This is useful for multi-stage resolution (e.g., Downstream Serverless Dashboard resolves params after local resolution).
|
|
887
|
+
|
|
888
|
+
> **Note:** This option does NOT apply to `self:` or dotProp variables (e.g., `${foo.bar.baz}`). These are local references that configorama fully owns—if they can't be resolved, it's a config error, not something to defer to another system.
|
|
878
889
|
|
|
879
890
|
## FAQ
|
|
880
891
|
|
|
892
|
+
**Q: What happens with circular variable dependencies?**
|
|
893
|
+
|
|
894
|
+
Configorama detects circular dependencies and throws a helpful error instead of hanging forever.
|
|
895
|
+
|
|
896
|
+
```yml
|
|
897
|
+
# Direct cycle - throws error
|
|
898
|
+
a: ${self:b}
|
|
899
|
+
b: ${self:a}
|
|
900
|
+
# Error: Circular variable dependency detected: b → a → b
|
|
901
|
+
|
|
902
|
+
# Indirect cycle - also detected
|
|
903
|
+
a: ${self:b}
|
|
904
|
+
b: ${self:c}
|
|
905
|
+
c: ${self:a}
|
|
906
|
+
# Error: Circular variable dependency detected: c → a → b → c
|
|
907
|
+
|
|
908
|
+
# Works with shorthand syntax too
|
|
909
|
+
foo:
|
|
910
|
+
bar: ${baz.qux}
|
|
911
|
+
baz:
|
|
912
|
+
qux: ${foo.bar}
|
|
913
|
+
# Error: Circular variable dependency detected: baz.qux → foo.bar → baz.qux
|
|
914
|
+
```
|
|
915
|
+
|
|
881
916
|
**Q: Why should I use this?**
|
|
882
917
|
|
|
883
918
|
Never rendering a stale configuration file again!
|
package/package.json
CHANGED
package/src/main.js
CHANGED
|
@@ -121,6 +121,7 @@ class Configorama {
|
|
|
121
121
|
allowUndefinedValues: false,
|
|
122
122
|
// Allow known variable types that can't be resolved to pass through
|
|
123
123
|
// Can be: false | true | ['param', 'file', 'env', ...]
|
|
124
|
+
// Note: Does not apply to self: or dotprop refs - those always error
|
|
124
125
|
allowUnresolvedVariables: false,
|
|
125
126
|
// Return metadata
|
|
126
127
|
returnMetadata: false,
|
|
@@ -334,21 +335,25 @@ class Configorama {
|
|
|
334
335
|
/** */
|
|
335
336
|
/* its file ref so we need to shift lookup for self in nested files */
|
|
336
337
|
if (valueObject.isFileRef) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// console.log('fallThroughSelfMatcher deeper', deeperExists)
|
|
345
|
-
return deeperExists
|
|
338
|
+
// First check if property exists in the nested file's context (preferred)
|
|
339
|
+
const nestedPath = [valueObject.path[0]].concat(varString)
|
|
340
|
+
const nestedDotPath = nestedPath.join('.')
|
|
341
|
+
if (dotProp.has(fullObject, nestedDotPath)) {
|
|
342
|
+
// Property exists in nested context - return true to indicate match
|
|
343
|
+
// (actual value resolution happens in resolver, not here)
|
|
344
|
+
return true
|
|
346
345
|
}
|
|
346
|
+
// Fall back to top-level lookup
|
|
347
|
+
if (dotProp.has(fullObject, varString)) {
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
return false
|
|
347
351
|
}
|
|
348
352
|
// console.log('fallthrough fullObject', fullObject)
|
|
349
353
|
/* is simple ${whatever} reference in same file */
|
|
350
354
|
const startOf = varString.split('.')
|
|
351
|
-
|
|
355
|
+
// Use has() to properly check existence for falsy values
|
|
356
|
+
return dotProp.has(fullObject, startOf[0])
|
|
352
357
|
},
|
|
353
358
|
resolver: (varString, options, config, pathValue) => {
|
|
354
359
|
/*
|
|
@@ -2767,6 +2772,25 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
2767
2772
|
|
|
2768
2773
|
// console.log('getValueFromSrc propertyString', propertyString)
|
|
2769
2774
|
// console.log(`tracker contains ${variableString}`, this.tracker.contains(variableString))
|
|
2775
|
+
|
|
2776
|
+
// Cycle detection: track dependencies and check for cycles
|
|
2777
|
+
const fromPath = valueObject.path ? valueObject.path.join('.') : null
|
|
2778
|
+
// Extract target path from variableString (e.g., 'self:b' → 'b', 'b.c' → 'b.c')
|
|
2779
|
+
let toPath = variableString
|
|
2780
|
+
if (variableString.startsWith('self:')) {
|
|
2781
|
+
toPath = variableString.slice(5)
|
|
2782
|
+
}
|
|
2783
|
+
// For cycle detection, only track self-references
|
|
2784
|
+
if (fromPath && (variableString.startsWith('self:') || !variableString.includes(':'))) {
|
|
2785
|
+
if (this.tracker.wouldCreateCycle(fromPath, toPath)) {
|
|
2786
|
+
const cyclePath = this.tracker.getCyclePath(fromPath, toPath)
|
|
2787
|
+
return Promise.reject(new Error(
|
|
2788
|
+
`Circular variable dependency detected: ${cyclePath.join(' → ')}`
|
|
2789
|
+
))
|
|
2790
|
+
}
|
|
2791
|
+
this.tracker.addDependency(fromPath, toPath)
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2770
2794
|
if (this.tracker.contains(variableString)) {
|
|
2771
2795
|
// console.log('try to get', variableString)
|
|
2772
2796
|
return this.tracker.get(variableString, propertyString)
|
|
@@ -2873,7 +2897,7 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
2873
2897
|
// Find the most recent call for this variableString
|
|
2874
2898
|
for (let i = this.resolutionTracking[pathKey].calls.length - 1; i >= 0; i--) {
|
|
2875
2899
|
if (this.resolutionTracking[pathKey].calls[i].variableString === variableString) {
|
|
2876
|
-
const v = (typeof val === 'object' && val.__internal_only_flag) ? val.value : val
|
|
2900
|
+
const v = (val && typeof val === 'object' && val.__internal_only_flag) ? val.value : val
|
|
2877
2901
|
this.resolutionTracking[pathKey].calls[i].resolvedValue = v
|
|
2878
2902
|
this.resolutionTracking[pathKey].calls[i].resolverType = resolverType
|
|
2879
2903
|
break
|
|
@@ -3244,15 +3268,16 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
3244
3268
|
|
|
3245
3269
|
/* its file ref so we need to shift lookup for self in nested files */
|
|
3246
3270
|
if (data.isFileRef) {
|
|
3247
|
-
|
|
3248
|
-
const
|
|
3249
|
-
|
|
3250
|
-
if (
|
|
3251
|
-
//
|
|
3252
|
-
deepProperties =
|
|
3253
|
-
|
|
3254
|
-
|
|
3271
|
+
// First check if property exists in the nested file's context (preferred for file refs)
|
|
3272
|
+
const nestedPath = [data.path[0]].concat(deepProperties)
|
|
3273
|
+
const nestedDotPath = nestedPath.join('.')
|
|
3274
|
+
if (dotProp.has(valueToPopulate, nestedDotPath)) {
|
|
3275
|
+
// Property exists in nested context, prefer it over top-level
|
|
3276
|
+
deepProperties = nestedPath
|
|
3277
|
+
}
|
|
3278
|
+
// Otherwise, keep deepProperties as-is to try top-level lookup
|
|
3255
3279
|
}
|
|
3280
|
+
|
|
3256
3281
|
return this.getDeeperValue(deepProperties, valueToPopulate).then((res) => {
|
|
3257
3282
|
/*
|
|
3258
3283
|
console.log('self getDeeperValue variableString', variableString)
|
|
@@ -3426,7 +3451,7 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
3426
3451
|
})
|
|
3427
3452
|
}
|
|
3428
3453
|
runFunction(variableString) {
|
|
3429
|
-
console.log('runFunction', variableString)
|
|
3454
|
+
// console.log('runFunction', variableString)
|
|
3430
3455
|
/* If json object value return it */
|
|
3431
3456
|
if (variableString.match(/^\s*{/) && variableString.match(/}\s*$/)) {
|
|
3432
3457
|
return variableString
|
|
@@ -3452,7 +3477,14 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
3452
3477
|
// TODO fix how commas + spaces are ned
|
|
3453
3478
|
const splitter = splitCsv(rawArgs, ', ')
|
|
3454
3479
|
// console.log('splitter', splitter)
|
|
3455
|
-
|
|
3480
|
+
// Recursively evaluate any nested function calls in arguments
|
|
3481
|
+
const evaluatedArgs = splitter.map((arg) => {
|
|
3482
|
+
if (typeof arg === 'string' && funcRegex.test(arg)) {
|
|
3483
|
+
return this.runFunction(arg)
|
|
3484
|
+
}
|
|
3485
|
+
return arg
|
|
3486
|
+
})
|
|
3487
|
+
argsToPass = formatFunctionArgs(evaluatedArgs)
|
|
3456
3488
|
}
|
|
3457
3489
|
// console.log('argsToPass runFunction', argsToPass)
|
|
3458
3490
|
// TODO check for camelCase version. | toUpperCase messes with function name
|
package/src/parsers/yaml.js
CHANGED
|
@@ -132,8 +132,8 @@ function preProcess(ymlStr = '') {
|
|
|
132
132
|
if (txt.indexOf(`'${nested}'`) > -1) {
|
|
133
133
|
return
|
|
134
134
|
}
|
|
135
|
-
/* Replace variable wrapped in quotes */
|
|
136
|
-
fixedText = fixedText.
|
|
135
|
+
/* Replace ALL occurrences of variable wrapped in quotes */
|
|
136
|
+
fixedText = fixedText.replaceAll(nested, `"${nested}"`)
|
|
137
137
|
})
|
|
138
138
|
ymlStr = ymlStr.replace(txt, fixedText)
|
|
139
139
|
}
|
|
@@ -171,8 +171,8 @@ function preProcess(ymlStr = '') {
|
|
|
171
171
|
if (txt.indexOf(`'${nested}'`) > -1) {
|
|
172
172
|
return
|
|
173
173
|
}
|
|
174
|
-
/* Replace variable wrapped in quotes */
|
|
175
|
-
fixedText = fixedText.
|
|
174
|
+
/* Replace ALL occurrences of variable wrapped in quotes */
|
|
175
|
+
fixedText = fixedText.replaceAll(nested, `"${nested}"`)
|
|
176
176
|
})
|
|
177
177
|
ymlStr = ymlStr.replace(txt, fixedText)
|
|
178
178
|
}
|
package/src/parsers/yaml.test.js
CHANGED
|
@@ -166,4 +166,56 @@ test('preProcess - should handle empty input', () => {
|
|
|
166
166
|
assert.equal(result, '')
|
|
167
167
|
})
|
|
168
168
|
|
|
169
|
+
// ==========================================
|
|
170
|
+
// Duplicate variable tests - Bug: String.replace() only replaces first occurrence
|
|
171
|
+
// ==========================================
|
|
172
|
+
|
|
173
|
+
test('preProcess - should wrap ALL duplicate variables in quotes within same array', () => {
|
|
174
|
+
const input = `
|
|
175
|
+
items: [\${var:foo}, \${var:foo}, \${var:foo}]
|
|
176
|
+
`
|
|
177
|
+
const result = preProcess(input)
|
|
178
|
+
|
|
179
|
+
// All three occurrences should be wrapped
|
|
180
|
+
const wrappedCount = (result.match(/"\$\{var:foo\}"/g) || []).length
|
|
181
|
+
|
|
182
|
+
assert.is(wrappedCount, 3, `Expected 3 wrapped occurrences, got ${wrappedCount}. Output: ${result}`)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('preProcess - should wrap ALL duplicate variables in quotes within same object', () => {
|
|
186
|
+
const input = `
|
|
187
|
+
config: {stage: \${env:STAGE}, region: \${env:STAGE}}
|
|
188
|
+
`
|
|
189
|
+
const result = preProcess(input)
|
|
190
|
+
|
|
191
|
+
// Both occurrences should be wrapped
|
|
192
|
+
const wrappedCount = (result.match(/"\$\{env:STAGE\}"/g) || []).length
|
|
193
|
+
|
|
194
|
+
assert.is(wrappedCount, 2, `Expected 2 wrapped occurrences, got ${wrappedCount}. Output: ${result}`)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('preProcess - should wrap duplicate variables mixed with unique variables in array', () => {
|
|
198
|
+
const input = `
|
|
199
|
+
mixed: [\${env:FOO}, \${env:BAR}, \${env:FOO}]
|
|
200
|
+
`
|
|
201
|
+
const result = preProcess(input)
|
|
202
|
+
|
|
203
|
+
const fooCount = (result.match(/"\$\{env:FOO\}"/g) || []).length
|
|
204
|
+
const barCount = (result.match(/"\$\{env:BAR\}"/g) || []).length
|
|
205
|
+
|
|
206
|
+
assert.is(fooCount, 2, `Expected 2 wrapped FOO occurrences, got ${fooCount}. Output: ${result}`)
|
|
207
|
+
assert.is(barCount, 1, `Expected 1 wrapped BAR occurrence, got ${barCount}. Output: ${result}`)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('preProcess - should wrap duplicate variables in nested array structure', () => {
|
|
211
|
+
const input = `
|
|
212
|
+
nested: [[\${opt:stage}, \${opt:stage}], [\${opt:stage}]]
|
|
213
|
+
`
|
|
214
|
+
const result = preProcess(input)
|
|
215
|
+
|
|
216
|
+
const wrappedCount = (result.match(/"\$\{opt:stage\}"/g) || []).length
|
|
217
|
+
|
|
218
|
+
assert.is(wrappedCount, 3, `Expected 3 wrapped occurrences, got ${wrappedCount}. Output: ${result}`)
|
|
219
|
+
})
|
|
220
|
+
|
|
169
221
|
test.run()
|
|
@@ -253,7 +253,19 @@ ${JSON.stringify(options.context, null, 2)}`,
|
|
|
253
253
|
if (fileExtension === 'js' || fileExtension === 'cjs') {
|
|
254
254
|
const jsFile = require(fullFilePath)
|
|
255
255
|
const { moduleName } = parseModuleReference(variableString, matchedFileString)
|
|
256
|
-
|
|
256
|
+
// For default export functions with :property syntax, keep the function and use deep properties
|
|
257
|
+
// For named exports (non-function module), look up the named export
|
|
258
|
+
let returnValueFunction = jsFile
|
|
259
|
+
let includeFirstProperty = false
|
|
260
|
+
|
|
261
|
+
if (moduleName && typeof jsFile === 'function') {
|
|
262
|
+
// Default export function with property access - include first property in path
|
|
263
|
+
returnValueFunction = jsFile
|
|
264
|
+
includeFirstProperty = true
|
|
265
|
+
} else if (moduleName) {
|
|
266
|
+
// Named export - look it up directly
|
|
267
|
+
returnValueFunction = jsFile[moduleName]
|
|
268
|
+
}
|
|
257
269
|
|
|
258
270
|
return processExecutableFile({
|
|
259
271
|
fileModule: jsFile,
|
|
@@ -264,7 +276,8 @@ ${JSON.stringify(options.context, null, 2)}`,
|
|
|
264
276
|
matchedFileString,
|
|
265
277
|
relativePath,
|
|
266
278
|
fileType: 'javascript',
|
|
267
|
-
getDeeperValue: ctx.getDeeperValue
|
|
279
|
+
getDeeperValue: ctx.getDeeperValue,
|
|
280
|
+
includeFirstProperty
|
|
268
281
|
})
|
|
269
282
|
}
|
|
270
283
|
|
|
@@ -8,6 +8,8 @@ class PromiseTracker {
|
|
|
8
8
|
reset() {
|
|
9
9
|
this.promiseList = []
|
|
10
10
|
this.promiseMap = {}
|
|
11
|
+
// Track which variables depend on which (for cycle detection)
|
|
12
|
+
this.dependencyGraph = {}
|
|
11
13
|
this.startTime = Date.now()
|
|
12
14
|
this.cursor = 0
|
|
13
15
|
}
|
|
@@ -81,6 +83,58 @@ class PromiseTracker {
|
|
|
81
83
|
promise.waitList += ` ${specifier}`
|
|
82
84
|
return promise
|
|
83
85
|
}
|
|
86
|
+
// Add a dependency edge: "from" depends on "to"
|
|
87
|
+
addDependency(from, to) {
|
|
88
|
+
if (!this.dependencyGraph[from]) {
|
|
89
|
+
this.dependencyGraph[from] = new Set()
|
|
90
|
+
}
|
|
91
|
+
this.dependencyGraph[from].add(to)
|
|
92
|
+
}
|
|
93
|
+
// Check if adding dependency from → to would create a cycle
|
|
94
|
+
wouldCreateCycle(from, to) {
|
|
95
|
+
// Check if "to" can reach "from" (meaning from → to would close a cycle)
|
|
96
|
+
const visited = new Set()
|
|
97
|
+
const stack = [to]
|
|
98
|
+
while (stack.length > 0) {
|
|
99
|
+
const current = stack.pop()
|
|
100
|
+
if (current === from) {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
if (visited.has(current)) {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
visited.add(current)
|
|
107
|
+
const deps = this.dependencyGraph[current]
|
|
108
|
+
if (deps) {
|
|
109
|
+
for (const dep of deps) {
|
|
110
|
+
stack.push(dep)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
// Get the cycle path for error reporting
|
|
117
|
+
getCyclePath(from, to) {
|
|
118
|
+
const path = [from, to]
|
|
119
|
+
const visited = new Set([from, to])
|
|
120
|
+
let current = to
|
|
121
|
+
while (current !== from) {
|
|
122
|
+
const deps = this.dependencyGraph[current]
|
|
123
|
+
if (!deps) break
|
|
124
|
+
let found = false
|
|
125
|
+
for (const dep of deps) {
|
|
126
|
+
if (dep === from || !visited.has(dep)) {
|
|
127
|
+
path.push(dep)
|
|
128
|
+
visited.add(dep)
|
|
129
|
+
current = dep
|
|
130
|
+
found = true
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!found) break
|
|
135
|
+
}
|
|
136
|
+
return path
|
|
137
|
+
}
|
|
84
138
|
getPending() {
|
|
85
139
|
return this.promiseList.filter(p => (p.state === 'pending'))
|
|
86
140
|
}
|
|
@@ -20,7 +20,7 @@ function decodeUnknown(rawValue) {
|
|
|
20
20
|
let val = rawValue.replace(PASSTHROUGH_PATTERN, '')
|
|
21
21
|
if (x.length) {
|
|
22
22
|
x.forEach(({ match, value }) => {
|
|
23
|
-
const decodedValue = Buffer.from(value, 'base64').toString('
|
|
23
|
+
const decodedValue = Buffer.from(value, 'base64').toString('utf8')
|
|
24
24
|
val = val.replace(match, decodedValue)
|
|
25
25
|
})
|
|
26
26
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for unknown-values.js - encoding/decoding passthrough variables
|
|
3
|
+
*
|
|
4
|
+
* These functions handle variables that cannot be resolved by Configorama
|
|
5
|
+
* but need to be passed through to external systems (like Serverless Dashboard).
|
|
6
|
+
*/
|
|
7
|
+
const { test } = require('uvu')
|
|
8
|
+
const assert = require('uvu/assert')
|
|
9
|
+
const {
|
|
10
|
+
encodeUnknown,
|
|
11
|
+
decodeUnknown,
|
|
12
|
+
findUnknownValues,
|
|
13
|
+
hasEncodedUnknown,
|
|
14
|
+
PASSTHROUGH_PATTERN
|
|
15
|
+
} = require('./unknown-values')
|
|
16
|
+
|
|
17
|
+
// ==========================================
|
|
18
|
+
// encodeUnknown tests
|
|
19
|
+
// ==========================================
|
|
20
|
+
|
|
21
|
+
test('encodeUnknown - encodes string with passthrough wrapper', () => {
|
|
22
|
+
const result = encodeUnknown('${param:test}')
|
|
23
|
+
assert.ok(result.startsWith('>passthrough[_['))
|
|
24
|
+
assert.ok(result.endsWith(']_]'))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('encodeUnknown - produces valid base64', () => {
|
|
28
|
+
const result = encodeUnknown('${param:test}')
|
|
29
|
+
const base64Part = result.replace('>passthrough[_[', '').replace(']_]', '')
|
|
30
|
+
// Should not throw
|
|
31
|
+
const decoded = Buffer.from(base64Part, 'base64').toString('utf8')
|
|
32
|
+
assert.is(decoded, '${param:test}')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// ==========================================
|
|
36
|
+
// decodeUnknown tests - basic functionality
|
|
37
|
+
// ==========================================
|
|
38
|
+
|
|
39
|
+
test('decodeUnknown - decodes encoded value back to original', () => {
|
|
40
|
+
const input = '${param:simpleValue}'
|
|
41
|
+
const encoded = encodeUnknown(input)
|
|
42
|
+
const decoded = decodeUnknown(encoded)
|
|
43
|
+
assert.is(decoded, input)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('decodeUnknown - handles multiple encoded values', () => {
|
|
47
|
+
const input1 = '${param:first}'
|
|
48
|
+
const input2 = '${param:second}'
|
|
49
|
+
const combined = `prefix ${encodeUnknown(input1)} middle ${encodeUnknown(input2)} suffix`
|
|
50
|
+
const decoded = decodeUnknown(combined)
|
|
51
|
+
assert.is(decoded, `prefix ${input1} middle ${input2} suffix`)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('decodeUnknown - returns original string if no encoded values', () => {
|
|
55
|
+
const input = 'no encoded values here'
|
|
56
|
+
const result = decodeUnknown(input)
|
|
57
|
+
assert.is(result, input)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ==========================================
|
|
61
|
+
// decodeUnknown tests - UTF-8 support (bug fix)
|
|
62
|
+
// ==========================================
|
|
63
|
+
|
|
64
|
+
test('encodeUnknown/decodeUnknown - handles UTF-8 accented characters', () => {
|
|
65
|
+
const input = '${param:café}'
|
|
66
|
+
const encoded = encodeUnknown(input)
|
|
67
|
+
const decoded = decodeUnknown(encoded)
|
|
68
|
+
assert.is(decoded, input, 'UTF-8 accented characters should decode correctly')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('encodeUnknown/decodeUnknown - handles UTF-8 emoji', () => {
|
|
72
|
+
const input = '${param:celebration-🎉}'
|
|
73
|
+
const encoded = encodeUnknown(input)
|
|
74
|
+
const decoded = decodeUnknown(encoded)
|
|
75
|
+
assert.is(decoded, input, 'UTF-8 emoji should decode correctly')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('encodeUnknown/decodeUnknown - handles multi-byte UTF-8 characters (Japanese)', () => {
|
|
79
|
+
const input = '${param:日本語}'
|
|
80
|
+
const encoded = encodeUnknown(input)
|
|
81
|
+
const decoded = decodeUnknown(encoded)
|
|
82
|
+
assert.is(decoded, input, 'Multi-byte UTF-8 characters should decode correctly')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('encodeUnknown/decodeUnknown - handles Chinese characters', () => {
|
|
86
|
+
const input = '${param:中文测试}'
|
|
87
|
+
const encoded = encodeUnknown(input)
|
|
88
|
+
const decoded = decodeUnknown(encoded)
|
|
89
|
+
assert.is(decoded, input, 'Chinese characters should decode correctly')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('encodeUnknown/decodeUnknown - handles mixed ASCII and UTF-8', () => {
|
|
93
|
+
const input = '${param:hello-世界-🌍}'
|
|
94
|
+
const encoded = encodeUnknown(input)
|
|
95
|
+
const decoded = decodeUnknown(encoded)
|
|
96
|
+
assert.is(decoded, input, 'Mixed ASCII and UTF-8 should decode correctly')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('encodeUnknown/decodeUnknown - handles special UTF-8 punctuation', () => {
|
|
100
|
+
const input = '${param:price-€100-£50}'
|
|
101
|
+
const encoded = encodeUnknown(input)
|
|
102
|
+
const decoded = decodeUnknown(encoded)
|
|
103
|
+
assert.is(decoded, input, 'UTF-8 currency symbols should decode correctly')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ==========================================
|
|
107
|
+
// findUnknownValues tests
|
|
108
|
+
// ==========================================
|
|
109
|
+
|
|
110
|
+
test('findUnknownValues - finds encoded values in text', () => {
|
|
111
|
+
const encoded = encodeUnknown('${param:test}')
|
|
112
|
+
const results = findUnknownValues(encoded)
|
|
113
|
+
assert.is(results.length, 1)
|
|
114
|
+
assert.ok(results[0].match.startsWith('[_['))
|
|
115
|
+
assert.ok(results[0].value.length > 0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('findUnknownValues - returns empty array for text without encoded values', () => {
|
|
119
|
+
const results = findUnknownValues('no encoded values')
|
|
120
|
+
assert.is(results.length, 0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('findUnknownValues - finds multiple encoded values', () => {
|
|
124
|
+
const text = `${encodeUnknown('${a}')} and ${encodeUnknown('${b}')}`
|
|
125
|
+
const results = findUnknownValues(text)
|
|
126
|
+
assert.is(results.length, 2)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ==========================================
|
|
130
|
+
// hasEncodedUnknown tests
|
|
131
|
+
// ==========================================
|
|
132
|
+
|
|
133
|
+
test('hasEncodedUnknown - returns true for encoded values', () => {
|
|
134
|
+
const encoded = encodeUnknown('${param:test}')
|
|
135
|
+
assert.is(hasEncodedUnknown(encoded), true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('hasEncodedUnknown - returns false for plain text', () => {
|
|
139
|
+
assert.is(hasEncodedUnknown('plain text'), false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('hasEncodedUnknown - returns false for regular variables', () => {
|
|
143
|
+
assert.is(hasEncodedUnknown('${param:test}'), false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test.run()
|
package/src/utils/lodash.js
CHANGED
|
@@ -32,14 +32,15 @@ function set(object, path, value) {
|
|
|
32
32
|
const lastIndex = keys.length - 1;
|
|
33
33
|
|
|
34
34
|
for (let i = 0; i < lastIndex; i++) {
|
|
35
|
-
const key = keys[i]
|
|
35
|
+
const key = keys[i]
|
|
36
36
|
|
|
37
|
-
if (
|
|
37
|
+
// Check if value is undefined, null, or not an object (primitives can't have properties)
|
|
38
|
+
if (current[key] == null || typeof current[key] !== 'object') {
|
|
38
39
|
// Create appropriate container based on next key type
|
|
39
|
-
current[key] = Number.isInteger(keys[i + 1]) && keys[i + 1] >= 0 ? [] : {}
|
|
40
|
+
current[key] = Number.isInteger(keys[i + 1]) && keys[i + 1] >= 0 ? [] : {}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
current = current[key]
|
|
43
|
+
current = current[key]
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
current[keys[lastIndex]] = value;
|