configorama 0.11.0 → 1.0.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 (78) hide show
  1. package/README.md +429 -123
  2. package/cli.js +282 -49
  3. package/index.d.ts +43 -1
  4. package/package.json +5 -1
  5. package/src/capabilities.js +59 -0
  6. package/src/capabilities.test.js +44 -0
  7. package/src/display.js +70 -7
  8. package/src/display.test.js +82 -0
  9. package/src/errors.js +73 -0
  10. package/src/index.js +91 -1
  11. package/src/main.js +159 -19
  12. package/src/parsers/esm.js +1 -16
  13. package/src/parsers/typescript.js +1 -48
  14. package/src/resolvers/valueFromCron.js +4 -25
  15. package/src/resolvers/valueFromEval.js +11 -1
  16. package/src/resolvers/valueFromFile.js +8 -1
  17. package/src/resolvers/valueFromGit.js +43 -17
  18. package/src/resolvers/valueFromOptions.js +5 -4
  19. package/src/utils/filters/filterArgs.js +57 -0
  20. package/src/utils/filters/oneOf.js +77 -0
  21. package/src/utils/introspection/audit.js +78 -0
  22. package/src/utils/introspection/graph.js +43 -0
  23. package/src/utils/introspection/model.js +150 -0
  24. package/src/utils/introspection/model.test.js +93 -0
  25. package/src/utils/parsing/commentAnnotations.js +107 -0
  26. package/src/utils/parsing/commentAnnotations.test.js +123 -0
  27. package/src/utils/parsing/enrichMetadata.js +64 -1
  28. package/src/utils/parsing/enrichMetadata.test.js +84 -0
  29. package/src/utils/parsing/extractComment.js +145 -0
  30. package/src/utils/parsing/extractComment.test.js +182 -0
  31. package/src/utils/parsing/preProcess.js +2 -1
  32. package/src/utils/paths/findLineForKey.js +2 -2
  33. package/src/utils/paths/ignorePaths.js +22 -9
  34. package/src/utils/redaction/redact.js +78 -0
  35. package/src/utils/redaction/redact.test.js +38 -0
  36. package/src/utils/redaction/setupRedaction.js +47 -0
  37. package/src/utils/redaction/setupRedaction.test.js +68 -0
  38. package/src/utils/requirements/configRequirements.js +351 -0
  39. package/src/utils/requirements/configRequirements.test.js +380 -0
  40. package/src/utils/requirements/serializeRequirements.js +120 -0
  41. package/src/utils/requirements/serializeRequirements.test.js +211 -0
  42. package/src/utils/security/evalSafety.js +86 -0
  43. package/src/utils/security/evalSafety.test.js +61 -0
  44. package/src/utils/security/safetyPolicy.js +110 -0
  45. package/src/utils/security/safetyPolicy.test.js +29 -0
  46. package/src/utils/strings/didYouMean.js +70 -0
  47. package/src/utils/strings/didYouMean.test.js +52 -0
  48. package/src/utils/strings/formatFunctionArgs.js +6 -1
  49. package/src/utils/strings/splitByComma.js +5 -0
  50. package/src/utils/ui/configWizard.js +208 -34
  51. package/src/utils/ui/createEditorLink.js +17 -1
  52. package/src/utils/ui/promptDescriptors.js +196 -0
  53. package/src/utils/ui/promptDescriptors.test.js +162 -0
  54. package/src/utils/variables/cleanVariable.js +22 -0
  55. package/src/utils/variables/getVariableType.js +1 -0
  56. package/types/src/index.d.ts +0 -24
  57. package/types/src/index.d.ts.map +1 -1
  58. package/types/src/main.d.ts +16 -8
  59. package/types/src/main.d.ts.map +1 -1
  60. package/types/src/resolvers/valueFromFile.d.ts +0 -2
  61. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  62. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  63. package/types/src/resolvers/valueFromSelf.d.ts +1 -0
  64. package/types/src/resolvers/valueFromSelf.d.ts.map +1 -0
  65. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  66. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  67. package/types/src/utils/paths/findLineForKey.d.ts +0 -9
  68. package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
  69. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  70. package/types/src/utils/variables/variableUtils.d.ts +1 -1
  71. package/types/src/display.d.ts +0 -62
  72. package/types/src/display.d.ts.map +0 -1
  73. package/types/src/metadata.d.ts +0 -28
  74. package/types/src/metadata.d.ts.map +0 -1
  75. package/types/src/utils/BoundedMap.d.ts +0 -10
  76. package/types/src/utils/BoundedMap.d.ts.map +0 -1
  77. package/types/src/utils/paths/ignorePaths.d.ts +0 -5
  78. package/types/src/utils/paths/ignorePaths.d.ts.map +0 -1
@@ -0,0 +1,182 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const configorama = require('../../index')
8
+ const { extractComment, findCommentStart, getCommentMarkers } = require('./extractComment')
9
+
10
+ test('extractComment reads trailing inline YAML comments', () => {
11
+ const lines = ['apiKey: ${env:API_KEY} # API key from dashboard']
12
+ assert.equal(extractComment('apiKey', lines, '.yml'), {
13
+ description: 'API key from dashboard',
14
+ descriptionSource: 'comment',
15
+ })
16
+ })
17
+
18
+ test('extractComment reads leading block comments and drops decoration', () => {
19
+ const lines = [
20
+ '# --------',
21
+ '# Database host',
22
+ '# Used by app startup',
23
+ 'host: ${env:DB_HOST}',
24
+ ]
25
+ assert.equal(extractComment('host', lines, '.yaml'), {
26
+ description: 'Database host Used by app startup',
27
+ descriptionSource: 'leadingComment',
28
+ })
29
+ })
30
+
31
+ test('extractComment ignores comment markers inside variables and quotes', () => {
32
+ const lines = ['apiKey: ${env:API_KEY, "abc#123"} # Real comment']
33
+ const start = findCommentStart(lines[0], getCommentMarkers('.yml'))
34
+ assert.is(lines[0].slice(start.index), '# Real comment')
35
+ })
36
+
37
+ test('extractComment uses path-aware lookup for nested duplicate YAML keys', () => {
38
+ const lines = [
39
+ 'first:',
40
+ ' apiKey: ${env:FIRST_KEY} # First key',
41
+ 'second:',
42
+ ' apiKey: ${env:SECOND_KEY} # Second key',
43
+ ]
44
+ assert.equal(extractComment('second.apiKey', lines, '.yml'), {
45
+ description: 'Second key',
46
+ descriptionSource: 'comment',
47
+ })
48
+ })
49
+
50
+ test('extractComment skips JSON comments and handles failures without throwing', () => {
51
+ assert.is(extractComment('apiKey', ['"apiKey": "${env:API_KEY}" // ignored'], '.json'), null)
52
+ assert.is(extractComment(null, null, '.yml'), null)
53
+ })
54
+
55
+ test('extractComment handles JSON5 line comments', () => {
56
+ const lines = [
57
+ '{',
58
+ ' "apiKey": "${env:API_KEY}" // API key',
59
+ '}',
60
+ ]
61
+ assert.equal(extractComment('apiKey', lines, '.json5'), {
62
+ description: 'API key',
63
+ descriptionSource: 'comment',
64
+ })
65
+ })
66
+
67
+ test('extractComment parses leading YAML annotation tags', () => {
68
+ const lines = [
69
+ '# Stripe live secret key',
70
+ '# @from Stripe dashboard > Developers > API keys',
71
+ '# @sensitive true',
72
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
73
+ ]
74
+ assert.equal(extractComment('stripeSecret', lines, '.yml'), {
75
+ description: 'Stripe live secret key',
76
+ descriptionSource: 'leadingComment',
77
+ annotations: {
78
+ obtainHint: 'Stripe dashboard > Developers > API keys',
79
+ sensitive: true,
80
+ },
81
+ obtainHint: 'Stripe dashboard > Developers > API keys',
82
+ sensitive: true,
83
+ })
84
+ })
85
+
86
+ test('extractComment lets explicit @description override plain comment text', () => {
87
+ const lines = [
88
+ '# Plain comment loses to explicit description',
89
+ '# @description Stripe live secret key',
90
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
91
+ ]
92
+ assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
93
+ description: 'Stripe live secret key',
94
+ descriptionSource: 'commentTag',
95
+ annotations: {
96
+ description: 'Stripe live secret key',
97
+ },
98
+ })
99
+ })
100
+
101
+ test('extractComment parses inline tags without requiring a description', () => {
102
+ const lines = [
103
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY} # @from Stripe dashboard > Developers > API keys',
104
+ ]
105
+ assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
106
+ annotations: {
107
+ obtainHint: 'Stripe dashboard > Developers > API keys',
108
+ },
109
+ obtainHint: 'Stripe dashboard > Developers > API keys',
110
+ })
111
+ })
112
+
113
+ test('extractComment parses JSONC and HCL annotation comments', () => {
114
+ const jsoncLines = [
115
+ '{',
116
+ ' // @group Payments',
117
+ ' "stripeSecret": "${env:STRIPE_SECRET_KEY}"',
118
+ '}',
119
+ ]
120
+ assert.equal(extractComment('stripeSecret', jsoncLines, '.jsonc'), {
121
+ annotations: {
122
+ group: 'Payments',
123
+ },
124
+ group: 'Payments',
125
+ })
126
+
127
+ const hclLines = [
128
+ '// Stripe live secret key',
129
+ '// @from Stripe dashboard > Developers > API keys',
130
+ 'stripe_secret = "${env:STRIPE_SECRET_KEY}"',
131
+ ]
132
+ assert.equal(extractComment('stripe_secret', hclLines, '.hcl'), {
133
+ description: 'Stripe live secret key',
134
+ descriptionSource: 'leadingComment',
135
+ annotations: {
136
+ obtainHint: 'Stripe dashboard > Developers > API keys',
137
+ },
138
+ obtainHint: 'Stripe dashboard > Developers > API keys',
139
+ })
140
+ })
141
+
142
+ test('extractComment keeps unknown tag-shaped comments as plain description', () => {
143
+ const lines = [
144
+ '# @david rotate this key after launch',
145
+ 'stripeSecret: ${env:STRIPE_SECRET_KEY}',
146
+ ]
147
+ assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
148
+ description: '@david rotate this key after launch',
149
+ descriptionSource: 'leadingComment',
150
+ })
151
+ })
152
+
153
+ test('requirements model uses comments when help is absent and keeps help precedence', async () => {
154
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configorama-comments-'))
155
+ const configPath = path.join(dir, 'config.yml')
156
+ fs.writeFileSync(configPath, [
157
+ '# API key from dashboard',
158
+ 'apiKey: ${env:CONFIGORAMA_COMMENT_API_KEY}',
159
+ 'withHelp: ${env:CONFIGORAMA_COMMENT_HELP | help("Help wins")} # Comment loses',
160
+ 'nested:',
161
+ ' apiKey: ${env:CONFIGORAMA_COMMENT_NESTED} # Nested key',
162
+ ].join('\n'))
163
+
164
+ try {
165
+ const result = await configorama.analyze(configPath, {
166
+ instructions: true,
167
+ options: {}
168
+ })
169
+ const byVariable = Object.fromEntries(result.requirements.map(req => [req.variable, req]))
170
+
171
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_API_KEY'].description, 'API key from dashboard')
172
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_API_KEY'].descriptionSource, 'leadingComment')
173
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_HELP'].description, 'Help wins')
174
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_HELP'].descriptionSource, 'help')
175
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_NESTED'].description, 'Nested key')
176
+ assert.is(byVariable['env:CONFIGORAMA_COMMENT_NESTED'].descriptionSource, 'comment')
177
+ } finally {
178
+ fs.rmSync(dir, { recursive: true, force: true })
179
+ }
180
+ })
181
+
182
+ test.run()
@@ -26,7 +26,8 @@ function preProcess(configObject, variableSyntax, variableTypes, options = {}) {
26
26
  // Extract reference prefixes from variable types, or use defaults
27
27
  const refPrefixes = variableTypes && variableTypes.length > 0
28
28
  ? variableTypes
29
- .map(v => (v.prefix || v.type) + ':')
29
+ .flatMap(v => v.prefixes || [v.prefix || v.type])
30
+ .map(prefix => prefix + ':')
30
31
  .filter(p => p !== 'dot.prop:' && p !== 'string:' && p !== 'number:')
31
32
  : ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
32
33
 
@@ -19,8 +19,8 @@ function findLineForKey(keyToFind, lines, fileType) {
19
19
  if (fileType === '.yml' || fileType === '.yaml') {
20
20
  return new RegExp(`^\\s*${escapedKey}\\s*:`).test(line)
21
21
  }
22
- // TOML: key = or key=
23
- if (fileType === '.toml') {
22
+ // TOML/HCL: key = or key=
23
+ if (fileType === '.toml' || fileType === '.hcl') {
24
24
  return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
25
25
  }
26
26
  // JSON: "key": or "key" :
@@ -1,5 +1,6 @@
1
1
  const DEFAULT_IGNORE_PATHS = [
2
2
  '**.Fn::Sub',
3
+ '**.Fn::Sub.0',
3
4
  '**.Properties.Code.ZipFile',
4
5
  '**.Properties.FunctionCode',
5
6
  '**.Properties.UserData',
@@ -29,17 +30,29 @@ function patternToSegments(pattern) {
29
30
  }
30
31
 
31
32
  function matchSegments(patternSegments, pathSegments) {
32
- if (!patternSegments.length) return pathSegments.length === 0
33
+ return matchFrom(patternSegments, 0, pathSegments, 0)
34
+ }
33
35
 
34
- const [head, ...tail] = patternSegments
35
- if (head === '**') {
36
- if (matchSegments(tail, pathSegments)) return true
37
- return pathSegments.length > 0 && matchSegments(patternSegments, pathSegments.slice(1))
36
+ // Index-based glob match: '**' spans zero-or-more segments (with backtracking),
37
+ // '*' matches one segment, anything else matches literally. Avoids per-call array
38
+ // allocation (no destructuring/slice) that dominated resolution hot paths.
39
+ function matchFrom(pattern, pi, path, si) {
40
+ while (pi < pattern.length) {
41
+ const head = pattern[pi]
42
+ if (head === '**') {
43
+ // '**' consumes zero segments here...
44
+ if (matchFrom(pattern, pi + 1, path, si)) return true
45
+ // ...or one more path segment, still anchored on '**'
46
+ if (si >= path.length) return false
47
+ si++
48
+ continue
49
+ }
50
+ if (si >= path.length) return false
51
+ if (head !== '*' && head !== path[si]) return false
52
+ pi++
53
+ si++
38
54
  }
39
-
40
- if (!pathSegments.length) return false
41
- if (head !== '*' && head !== pathSegments[0]) return false
42
- return matchSegments(tail, pathSegments.slice(1))
55
+ return si === path.length
43
56
  }
44
57
 
45
58
  function normalizeIgnorePaths(options = {}) {
@@ -0,0 +1,78 @@
1
+ const dotProp = require('dot-prop')
2
+
3
+ const REDACTED_VALUE = '********'
4
+
5
+ const DEFAULT_SENSITIVE_PATTERNS = [
6
+ /secret/i,
7
+ /password/i,
8
+ /token/i,
9
+ /key/i,
10
+ /credential/i,
11
+ /auth/i,
12
+ /private[-_]?key/i,
13
+ /client[-_]?secret/i,
14
+ ]
15
+
16
+ function cloneJson(value) {
17
+ if (value === undefined) return undefined
18
+ return JSON.parse(JSON.stringify(value))
19
+ }
20
+
21
+ function toRegex(pattern) {
22
+ if (pattern instanceof RegExp) return pattern
23
+ return new RegExp(String(pattern), 'i')
24
+ }
25
+
26
+ function getSensitivePatterns(options = {}) {
27
+ const customPatterns = (options.patterns || options.sensitivePatterns || []).map(toRegex)
28
+ return DEFAULT_SENSITIVE_PATTERNS.concat(customPatterns)
29
+ }
30
+
31
+ function isSensitiveName(name, options = {}) {
32
+ if (!name) return false
33
+ const value = String(name)
34
+ return getSensitivePatterns(options).some(pattern => pattern.test(value))
35
+ }
36
+
37
+ function getSensitiveOverride(entries = []) {
38
+ const entry = entries.find(item => item && typeof item.value === 'boolean')
39
+ if (!entry) return null
40
+ return entry.value
41
+ }
42
+
43
+ function isSensitiveVariable(name, options = {}) {
44
+ const override = getSensitiveOverride(options.sensitiveEntries || [])
45
+ if (override !== null) return override
46
+ return isSensitiveName(name, options) || isSensitiveName(options.path, options)
47
+ }
48
+
49
+ function redactValue(value) {
50
+ if (value === undefined) return undefined
51
+ return REDACTED_VALUE
52
+ }
53
+
54
+ function redactObjectByPaths(value, paths = []) {
55
+ const redacted = cloneJson(value)
56
+ for (const configPath of paths || []) {
57
+ if (configPath && dotProp.has(redacted, configPath)) {
58
+ dotProp.set(redacted, configPath, REDACTED_VALUE)
59
+ }
60
+ }
61
+ return redacted
62
+ }
63
+
64
+ function redactRequirementValue(requirement, value) {
65
+ return requirement && requirement.sensitive === true ? REDACTED_VALUE : value
66
+ }
67
+
68
+ module.exports = {
69
+ DEFAULT_SENSITIVE_PATTERNS,
70
+ REDACTED_VALUE,
71
+ cloneJson,
72
+ getSensitivePatterns,
73
+ isSensitiveName,
74
+ isSensitiveVariable,
75
+ redactObjectByPaths,
76
+ redactRequirementValue,
77
+ redactValue,
78
+ }
@@ -0,0 +1,38 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const {
4
+ isSensitiveVariable,
5
+ redactObjectByPaths,
6
+ REDACTED_VALUE,
7
+ } = require('./redact')
8
+
9
+ test('isSensitiveVariable detects common secret names', () => {
10
+ assert.is(isSensitiveVariable('API_KEY'), true)
11
+ assert.is(isSensitiveVariable('clientSecret'), true)
12
+ assert.is(isSensitiveVariable('private_key'), true)
13
+ assert.is(isSensitiveVariable('region'), false)
14
+ })
15
+
16
+ test('isSensitiveVariable honors annotation overrides', () => {
17
+ assert.is(isSensitiveVariable('API_KEY', {
18
+ sensitiveEntries: [{ value: false, path: 'apiKey' }]
19
+ }), false)
20
+
21
+ assert.is(isSensitiveVariable('region', {
22
+ sensitiveEntries: [{ value: true, path: 'region' }]
23
+ }), true)
24
+ })
25
+
26
+ test('redactObjectByPaths redacts nested config paths', () => {
27
+ const redacted = redactObjectByPaths({
28
+ service: 'demo',
29
+ secrets: {
30
+ apiKey: 'secret-value'
31
+ }
32
+ }, ['secrets.apiKey'])
33
+
34
+ assert.is(redacted.service, 'demo')
35
+ assert.is(redacted.secrets.apiKey, REDACTED_VALUE)
36
+ })
37
+
38
+ test.run()
@@ -0,0 +1,47 @@
1
+ const dotProp = require('dot-prop')
2
+ const { REDACTED_VALUE, cloneJson } = require('./redact')
3
+
4
+ function inputSectionForRequirement(requirement) {
5
+ if (!requirement) return null
6
+ if (requirement.variableType === 'option') return 'options'
7
+ if (requirement.variableType === 'env') return 'env'
8
+ if (requirement.variableType === 'self') return 'self'
9
+ if (requirement.variableType === 'dotProp') return 'dotProp'
10
+ return null
11
+ }
12
+
13
+ function redactUserInputsByRequirements(userInputs, requirements) {
14
+ const redacted = cloneJson(userInputs || {})
15
+
16
+ for (const requirement of requirements || []) {
17
+ if (!requirement || requirement.sensitive !== true) continue
18
+ const section = inputSectionForRequirement(requirement)
19
+ if (!section || !redacted[section]) continue
20
+ if (Object.prototype.hasOwnProperty.call(redacted[section], requirement.name)) {
21
+ redacted[section][requirement.name] = REDACTED_VALUE
22
+ }
23
+ }
24
+
25
+ return redacted
26
+ }
27
+
28
+ function redactConfigByRequirements(config, requirements) {
29
+ const redacted = cloneJson(config)
30
+
31
+ for (const requirement of requirements || []) {
32
+ if (!requirement || requirement.sensitive !== true) continue
33
+ for (const configPath of requirement.paths || []) {
34
+ if (configPath && dotProp.has(redacted, configPath)) {
35
+ dotProp.set(redacted, configPath, REDACTED_VALUE)
36
+ }
37
+ }
38
+ }
39
+
40
+ return redacted
41
+ }
42
+
43
+ module.exports = {
44
+ REDACTED_VALUE,
45
+ redactConfigByRequirements,
46
+ redactUserInputsByRequirements,
47
+ }
@@ -0,0 +1,68 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const {
4
+ REDACTED_VALUE,
5
+ redactConfigByRequirements,
6
+ redactUserInputsByRequirements,
7
+ } = require('./setupRedaction')
8
+
9
+ const requirements = [
10
+ {
11
+ name: 'API_KEY',
12
+ variableType: 'env',
13
+ sensitive: true,
14
+ paths: ['secrets.apiKey'],
15
+ },
16
+ {
17
+ name: 'PUBLIC_KEY',
18
+ variableType: 'env',
19
+ sensitive: false,
20
+ paths: ['secrets.publicKey'],
21
+ },
22
+ {
23
+ name: 'stage',
24
+ variableType: 'option',
25
+ sensitive: false,
26
+ paths: ['stage'],
27
+ },
28
+ ]
29
+
30
+ test('redactUserInputsByRequirements redacts only sensitive setup values', () => {
31
+ const inputs = {
32
+ options: { stage: 'prod' },
33
+ env: {
34
+ API_KEY: 'secret-value',
35
+ PUBLIC_KEY: 'public-value',
36
+ }
37
+ }
38
+
39
+ assert.equal(redactUserInputsByRequirements(inputs, requirements), {
40
+ options: { stage: 'prod' },
41
+ env: {
42
+ API_KEY: REDACTED_VALUE,
43
+ PUBLIC_KEY: 'public-value',
44
+ }
45
+ })
46
+ assert.is(inputs.env.API_KEY, 'secret-value')
47
+ })
48
+
49
+ test('redactConfigByRequirements redacts only sensitive resolved config paths', () => {
50
+ const config = {
51
+ stage: 'prod',
52
+ secrets: {
53
+ apiKey: 'secret-value',
54
+ publicKey: 'public-value',
55
+ }
56
+ }
57
+
58
+ assert.equal(redactConfigByRequirements(config, requirements), {
59
+ stage: 'prod',
60
+ secrets: {
61
+ apiKey: REDACTED_VALUE,
62
+ publicKey: 'public-value',
63
+ }
64
+ })
65
+ assert.is(config.secrets.apiKey, 'secret-value')
66
+ })
67
+
68
+ test.run()