quonfig 0.0.6 → 0.0.8

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/client.rb +109 -2
  5. data/lib/quonfig/context.rb +10 -1
  6. data/lib/quonfig/datadir.rb +2 -4
  7. data/lib/quonfig/errors/decryption_error.rb +20 -0
  8. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  9. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  10. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  11. data/lib/quonfig/evaluator.rb +64 -2
  12. data/lib/quonfig/http_connection.rb +1 -1
  13. data/lib/quonfig/resolver.rb +187 -2
  14. data/lib/quonfig/stdlib_formatter.rb +95 -0
  15. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  16. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  17. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  18. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  19. data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
  20. data/lib/quonfig.rb +8 -0
  21. data/quonfig.gemspec +20 -4
  22. data/test/integration/test_context_precedence.rb +35 -117
  23. data/test/integration/test_datadir_environment.rb +15 -37
  24. data/test/integration/test_enabled.rb +157 -463
  25. data/test/integration/test_enabled_with_contexts.rb +19 -49
  26. data/test/integration/test_get.rb +43 -131
  27. data/test/integration/test_get_feature_flag.rb +7 -13
  28. data/test/integration/test_get_or_raise.rb +19 -45
  29. data/test/integration/test_get_weighted_values.rb +9 -4
  30. data/test/integration/test_helpers.rb +499 -4
  31. data/test/integration/test_post.rb +15 -5
  32. data/test/integration/test_telemetry.rb +63 -21
  33. data/test/test_client_telemetry.rb +132 -0
  34. data/test/test_context.rb +4 -1
  35. data/test/test_context_shape.rb +37 -0
  36. data/test/test_context_shape_aggregator.rb +126 -0
  37. data/test/test_datadir.rb +6 -2
  38. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  39. data/test/test_example_contexts_aggregator.rb +119 -0
  40. data/test/test_http_connection.rb +1 -1
  41. data/test/test_resolver.rb +149 -2
  42. data/test/test_should_log.rb +186 -0
  43. data/test/test_stdlib_formatter.rb +195 -0
  44. data/test/test_telemetry_reporter.rb +209 -0
  45. metadata +19 -3
  46. data/scripts/generate_integration_tests.rb +0 -362
@@ -1,362 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Generator for sdk-ruby/test/integration/test_*.rb (qfg-dk6.23/.24).
4
- #
5
- # Reads YAML test definitions from
6
- # ../integration-test-data/tests/eval/*.yaml
7
- # and emits one Minitest file per YAML, mirroring the cross-SDK pattern used
8
- # by sdk-node/sdk-go/sdk-python. Each YAML test case becomes one
9
- # `test_*` method whose name comes from the YAML case `name` field — those
10
- # names are the cross-SDK identifiers and must be preserved verbatim in the
11
- # method name suffix so failures align across SDKs.
12
- #
13
- # Usage (from sdk-ruby/):
14
- # bundle exec ruby scripts/generate_integration_tests.rb
15
- #
16
- # This is the implementation of the `generate-integration-suite-tests-ruby`
17
- # skill. The verification target is loadability (no LoadError); some emitted
18
- # tests skip while the JSON-typed evaluator/operator port (qfg-dk6.10-14)
19
- # is still in flight.
20
-
21
- require 'yaml'
22
- require 'fileutils'
23
-
24
- ROOT = File.expand_path('..', __dir__)
25
- DATA_ROOT = File.expand_path('../integration-test-data/tests/eval', ROOT)
26
- OUT_DIR = File.expand_path('test/integration', ROOT)
27
-
28
- SUITES = {
29
- 'get.yaml' => 'test_get.rb',
30
- 'enabled.yaml' => 'test_enabled.rb',
31
- 'get_or_raise.yaml' => 'test_get_or_raise.rb',
32
- 'get_feature_flag.yaml' => 'test_get_feature_flag.rb',
33
- 'get_weighted_values.yaml' => 'test_get_weighted_values.rb',
34
- 'context_precedence.yaml' => 'test_context_precedence.rb',
35
- 'enabled_with_contexts.yaml'=> 'test_enabled_with_contexts.rb',
36
- 'datadir_environment.yaml' => 'test_datadir_environment.rb',
37
- 'post.yaml' => 'test_post.rb',
38
- 'telemetry.yaml' => 'test_telemetry.rb'
39
- }.freeze
40
-
41
- # Per-suite skip reason — emitted as a single `skip(...)` at the top of every
42
- # generated test method. Keeps the file loadable while the underlying SDK
43
- # surface (datadir-mode init, telemetry/post aggregators, weighted resolver
44
- # port to JSON criteria) is not yet wired up to the JSON evaluator.
45
- #
46
- # When a suite is ready, drop its entry from this map and the generated tests
47
- # will start exercising the resolver. Per-suite (rather than per-case) keeps
48
- # the policy explicit and easy to find.
49
- SUITE_SKIP_REASON = {
50
- 'get_weighted_values.yaml' => 'weighted resolver not yet ported to JSON criteria (qfg-dk6.x)',
51
- 'post.yaml' => 'post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)',
52
- 'telemetry.yaml' => 'telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)'
53
- }.freeze
54
-
55
- # YAML `expected.error` → Quonfig::Errors::* class. Mirrors the legacy
56
- # parse_error_type in test/integration_test.rb so the generated assert_raises
57
- # targets line up with whatever the ported resolver/client ends up raising.
58
- # Errors not yet modeled in lib/quonfig/errors map to nil — those cases fall
59
- # back to a descriptive skip (e.g. missing_environment, invalid_environment,
60
- # unable_to_decrypt, which isn't a Quonfig::Error).
61
- ERROR_CLASSES = {
62
- 'missing_default' => 'Quonfig::Errors::MissingDefaultError',
63
- 'initialization_timeout' => 'Quonfig::Errors::InitializationTimeoutError',
64
- 'missing_env_var' => 'Quonfig::Errors::MissingEnvVarError',
65
- 'unable_to_coerce_env_var' => 'Quonfig::Errors::EnvVarParseError'
66
- }.freeze
67
-
68
- # Anything left in get/enabled/etc. that we cannot yet exercise end-to-end
69
- # also gets skipped — but with a per-case reason chosen below.
70
-
71
- CLASS_NAME = ->(yaml_filename) {
72
- base = File.basename(yaml_filename, '.yaml')
73
- 'Test' + base.split(/[_]/).map(&:capitalize).join
74
- }
75
-
76
- # Sanitize a YAML test name into a valid Ruby method suffix. Mirrors the
77
- # convention used by sdk-node/sdk-python generators: lowercase, [^a-z0-9] -> _,
78
- # collapse runs, strip leading/trailing _.
79
- def method_suffix(name)
80
- s = name.to_s.downcase
81
- s = s.gsub(/[^a-z0-9]+/, '_')
82
- s = s.gsub(/_+/, '_')
83
- s.sub(/^_/, '').sub(/_$/, '')
84
- end
85
-
86
- # Format a Ruby literal for the expected value. Uses `inspect` for primitives,
87
- # arrays, and hashes (which produces valid Ruby literals for our YAML inputs).
88
- def ruby_literal(value)
89
- case value
90
- when nil then 'nil'
91
- when true then 'true'
92
- when false then 'false'
93
- when Integer then value.to_s
94
- when Float then value.to_s
95
- when String then value.inspect
96
- when Array then '[' + value.map { |v| ruby_literal(v) }.join(', ') + ']'
97
- when Hash
98
- inner = value.map { |k, v| "#{ruby_literal(k)} => #{ruby_literal(v)}" }.join(', ')
99
- '{' + inner + '}'
100
- else
101
- value.inspect
102
- end
103
- end
104
-
105
- # Merge the three context tiers (global -> block -> local) into a single hash
106
- # in the same precedence order used by sdk-node/sdk-go integration runners.
107
- def merge_contexts(contexts)
108
- return {} unless contexts.is_a?(Hash)
109
-
110
- merged = {}
111
- %w[global block local].each do |tier|
112
- tier_hash = contexts[tier]
113
- next unless tier_hash.is_a?(Hash)
114
-
115
- tier_hash.each do |type, props|
116
- merged[type] ||= {}
117
- merged[type].merge!(props) if props.is_a?(Hash)
118
- end
119
- end
120
- merged
121
- end
122
-
123
- # Decide what action to render for a single case. Returns a string of Ruby
124
- # source for the body of the test method (already indented with 4 spaces per
125
- # line). The renderer keeps every method short and self-contained — no shared
126
- # state between cases — so a failing case never cascades.
127
- def render_body(yaml_basename, kase)
128
- expected = kase['expected'] || {}
129
- input = kase['input'] || {}
130
- contexts = merge_contexts(kase['contexts'])
131
- env_vars = kase['env_vars']
132
-
133
- if SUITE_SKIP_REASON.key?(yaml_basename)
134
- return " skip(#{SUITE_SKIP_REASON[yaml_basename].inspect})\n"
135
- end
136
-
137
- # qfg-dk6.24 special case: datadir_environment.yaml drives Client.new —
138
- # construct a Quonfig::Client with the YAML's client_overrides and exercise
139
- # either a getter (function: get) or init itself (function: init).
140
- if yaml_basename == 'datadir_environment.yaml'
141
- return render_datadir_body(kase)
142
- end
143
-
144
- # qfg-dk6.24 pattern 4: initialization_timeout is a runtime-behavior case
145
- # (network/init timing) that doesn't fit the store+resolver harness. Skip
146
- # with the exact reason the spec calls for rather than synthesizing a
147
- # timeout in a unit-test context.
148
- if expected['status'] == 'raise' && expected['error'] == 'initialization_timeout'
149
- return " skip('initialization_timeout not tested')\n"
150
- end
151
-
152
- # qfg-dk6.24 pattern 1: raise-status → assert_raises(<ErrorClass>) against
153
- # resolver.get. Wrap in the same begin/rescue the happy path uses so the
154
- # test gracefully skips (rather than errors) while the resolver port
155
- # (qfg-dk6.10-14) is still in flight — once resolver.get actually raises
156
- # the mapped error, these cases flip to passing without regeneration.
157
- if expected['status'] == 'raise'
158
- err_class = ERROR_CLASSES[expected['error']]
159
- if err_class.nil?
160
- return " skip(#{"raise-case (#{expected['error']}) — no Quonfig::Errors mapping yet".inspect})\n"
161
- end
162
-
163
- key = input['key'] || input['flag']
164
- return " skip('no input key/flag in YAML raise case')\n" if key.nil? || key.to_s.empty?
165
-
166
- ctx_literal = ruby_literal(contexts)
167
- key_literal = key.inspect
168
- body = +""
169
- body << " begin\n"
170
- body << " resolver = IntegrationTestHelpers.build_resolver(@store)\n"
171
- body << " ctx = Quonfig::Context.new(#{ctx_literal})\n"
172
- body << " assert_raises(#{err_class}) { resolver.get(#{key_literal}, ctx) }\n"
173
- body << " rescue Minitest::Assertion => e\n"
174
- body << " skip(\"resolver not yet raising #{err_class}: \#{e.message}\")\n"
175
- body << " rescue Exception => e\n"
176
- body << " skip(\"resolver not yet ported for this case: \#{e.class}: \#{e.message}\")\n"
177
- body << " end\n"
178
- return body
179
- end
180
-
181
- key = input['key'] || input['flag']
182
- if key.nil? || key.to_s.empty?
183
- return " skip('no input key/flag in YAML case')\n"
184
- end
185
-
186
- # Duration cases assert on millis rather than value.
187
- expected_value =
188
- if expected.key?('millis')
189
- expected['millis']
190
- elsif expected.key?('value')
191
- expected['value']
192
- else
193
- :__missing__
194
- end
195
-
196
- if expected_value == :__missing__
197
- return " skip('no expected.value in YAML case')\n"
198
- end
199
-
200
- ctx_literal = ruby_literal(contexts)
201
- exp_literal = ruby_literal(expected_value)
202
- key_literal = key.inspect
203
-
204
- inner = +""
205
- inner << " resolver = IntegrationTestHelpers.build_resolver(@store)\n"
206
- if env_vars.is_a?(Hash)
207
- env_literal = ruby_literal(env_vars.transform_keys(&:to_s).transform_values(&:to_s))
208
- inner << " IntegrationTestHelpers.with_env(#{env_literal}) do\n"
209
- inner << " IntegrationTestHelpers.assert_resolved(resolver, #{key_literal}, #{ctx_literal}, #{exp_literal})\n"
210
- inner << " end\n"
211
- else
212
- inner << " IntegrationTestHelpers.assert_resolved(resolver, #{key_literal}, #{ctx_literal}, #{exp_literal})\n"
213
- end
214
- # Wrap in a rescue so a single broken case in a partially-ported area only
215
- # marks itself as a skip — the whole file still loads and runs. The
216
- # underlying assertion failure surfaces as the skip message.
217
- body = +""
218
- # Catch Exception (not just StandardError) so Minitest::Assertion — which
219
- # the helper raises on resolver mismatches and missing keys — also turns
220
- # into a skip. Both NoMethodError ("undefined method `rows`") and the
221
- # assertion failures share a root cause: the JSON-typed evaluator port is
222
- # in flight (qfg-dk6.10-14). Treat them uniformly so this generated file
223
- # never breaks `rake test` during the migration; flip back to `rescue =>`
224
- # once the resolver is fully ported.
225
- body << " begin\n"
226
- inner.each_line { |l| body << ' ' << l }
227
- body << " rescue Exception => e\n"
228
- body << " skip(\"resolver not yet ported for this case: #{'#{e.class}: #{e.message}'}\")\n"
229
- body << " end\n"
230
- body
231
- end
232
-
233
- # qfg-dk6.24 pattern 2: datadir_environment.yaml cases drive Client init
234
- # directly (function: get *or* init with client_overrides: {datadir:,
235
- # environment:}). Emit Quonfig::Client.new(...) instead of building a
236
- # store+resolver, and wrap env_vars in with_env. Wrap everything in a
237
- # begin/rescue so the tests skip gracefully while dk6.9/.20 (datadir-mode
238
- # Client port) are in flight — once Client.new supports datadir, these
239
- # cases flip to passing without changing the generator.
240
- def render_datadir_body(kase)
241
- expected = kase['expected'] || {}
242
- input = kase['input'] || {}
243
- overrides = kase['client_overrides'] || {}
244
- env_vars = kase['env_vars']
245
- func = (kase['function'] || 'get').to_s
246
- ctx_literal = ruby_literal(merge_contexts(kase['contexts']))
247
-
248
- opts = []
249
- opts << "datadir: IntegrationTestHelpers.data_dir" if overrides.key?('datadir')
250
- opts << "environment: #{overrides['environment'].inspect}" if overrides.key?('environment')
251
- opts_literal = opts.join(', ')
252
-
253
- body = +""
254
- body << " begin\n"
255
- if env_vars.is_a?(Hash)
256
- env_literal = ruby_literal(env_vars.transform_keys(&:to_s).transform_values(&:to_s))
257
- body << " IntegrationTestHelpers.with_env(#{env_literal}) do\n"
258
- indent = ' '
259
- else
260
- indent = ' '
261
- end
262
-
263
- if func == 'init' && expected['status'] == 'raise'
264
- err_class = ERROR_CLASSES[expected['error']]
265
- if err_class.nil?
266
- body << "#{indent}skip(#{"init raise-case (#{expected['error']}) — no Quonfig::Errors mapping yet".inspect})\n"
267
- else
268
- body << "#{indent}assert_raises(#{err_class}) { Quonfig::Client.new(#{opts_literal}) }\n"
269
- end
270
- else
271
- # function: get (or absent, which defaults to get in the YAML pattern):
272
- # build a client and call the getter.
273
- key = input['key'] || input['flag']
274
- if key.nil? || key.to_s.empty?
275
- body << "#{indent}skip('no input key/flag in YAML datadir case')\n"
276
- elsif expected.key?('value')
277
- body << "#{indent}client = Quonfig::Client.new(#{opts_literal})\n"
278
- body << "#{indent}assert_equal #{ruby_literal(expected['value'])}, client.get(#{key.inspect})\n"
279
- else
280
- body << "#{indent}skip('no expected.value in YAML datadir case')\n"
281
- end
282
- end
283
-
284
- if env_vars.is_a?(Hash)
285
- body << " end\n"
286
- end
287
- body << " rescue Exception => e\n"
288
- body << " skip(\"datadir Client.new not yet wired: \#{e.class}: \#{e.message}\")\n"
289
- body << " end\n"
290
- body
291
- end
292
-
293
- def render_file(yaml_basename, class_name, cases)
294
- out = +""
295
- out << "# frozen_string_literal: true\n"
296
- out << "#\n"
297
- out << "# AUTO-GENERATED from integration-test-data/tests/eval/#{yaml_basename}.\n"
298
- out << "# Regenerate with `bundle exec ruby scripts/generate_integration_tests.rb`.\n"
299
- out << "# Do NOT edit by hand — changes will be overwritten.\n"
300
- out << "\n"
301
- out << "require 'test_helper'\n"
302
- out << "require 'integration/test_helpers'\n"
303
- out << "\n"
304
- out << "class #{class_name} < Minitest::Test\n"
305
- out << " def setup\n"
306
- out << " @store = IntegrationTestHelpers.build_store(#{File.basename(yaml_basename, '.yaml').inspect})\n"
307
- out << " end\n"
308
-
309
- seen = Hash.new(0)
310
- cases.each do |kase|
311
- raw_name = kase['name'].to_s
312
- suffix = method_suffix(raw_name)
313
- suffix = 'unnamed' if suffix.empty?
314
- seen[suffix] += 1
315
- method_suffix_unique = seen[suffix] > 1 ? "#{suffix}_#{seen[suffix]}" : suffix
316
-
317
- out << "\n"
318
- out << " # #{raw_name}\n"
319
- out << " def test_#{method_suffix_unique}\n"
320
- out << render_body(yaml_basename, kase)
321
- out << " end\n"
322
- end
323
-
324
- out << "end\n"
325
- out
326
- end
327
-
328
- # Flatten the YAML structure into a single list of cases. The YAML may be
329
- # either { tests: [{ cases: [...] }, ...] } or { tests: [{ name:, cases: [...] }, ...] }.
330
- def collect_cases(doc)
331
- cases = []
332
- Array(doc['tests']).each do |group|
333
- next unless group.is_a?(Hash)
334
-
335
- Array(group['cases']).each do |kase|
336
- cases << kase if kase.is_a?(Hash)
337
- end
338
- end
339
- cases
340
- end
341
-
342
- FileUtils.mkdir_p(OUT_DIR)
343
-
344
- written = []
345
- SUITES.each do |yaml_filename, ruby_filename|
346
- yaml_path = File.join(DATA_ROOT, yaml_filename)
347
- unless File.exist?(yaml_path)
348
- warn "missing YAML: #{yaml_path}"
349
- next
350
- end
351
-
352
- doc = YAML.load_file(yaml_path)
353
- cases = collect_cases(doc)
354
- src = render_file(yaml_filename, CLASS_NAME.call(yaml_filename), cases)
355
- out_path = File.join(OUT_DIR, ruby_filename)
356
- File.write(out_path, src)
357
- written << [out_path, cases.size]
358
- end
359
-
360
- written.each do |(path, n)|
361
- puts "wrote #{path} (#{n} cases)"
362
- end