quonfig 0.0.10 → 0.0.12

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +94 -0
  4. data/lib/quonfig/caching_http_connection.rb +3 -3
  5. data/lib/quonfig/client.rb +22 -27
  6. data/lib/quonfig/config_loader.rb +5 -1
  7. data/lib/quonfig/config_store.rb +10 -6
  8. data/lib/quonfig/context.rb +5 -4
  9. data/lib/quonfig/datadir.rb +4 -10
  10. data/lib/quonfig/dev_context.rb +4 -2
  11. data/lib/quonfig/duration.rb +2 -2
  12. data/lib/quonfig/encryption.rb +12 -16
  13. data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
  14. data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
  15. data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
  16. data/lib/quonfig/errors/missing_environment_error.rb +1 -1
  17. data/lib/quonfig/errors/uninitialized_error.rb +1 -1
  18. data/lib/quonfig/evaluation.rb +11 -8
  19. data/lib/quonfig/evaluator.rb +34 -37
  20. data/lib/quonfig/fixed_size_hash.rb +1 -0
  21. data/lib/quonfig/http_connection.rb +2 -4
  22. data/lib/quonfig/internal_logger.rb +63 -27
  23. data/lib/quonfig/murmer3.rb +2 -2
  24. data/lib/quonfig/options.rb +62 -75
  25. data/lib/quonfig/periodic_sync.rb +1 -1
  26. data/lib/quonfig/quonfig.rb +3 -3
  27. data/lib/quonfig/reason.rb +2 -1
  28. data/lib/quonfig/resolver.rb +8 -9
  29. data/lib/quonfig/semantic_logger_filter.rb +4 -3
  30. data/lib/quonfig/semver.rb +6 -8
  31. data/lib/quonfig/sse_config_client.rb +14 -15
  32. data/lib/quonfig/stdlib_formatter.rb +3 -3
  33. data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
  34. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
  35. data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
  36. data/lib/quonfig/time_helpers.rb +2 -0
  37. data/lib/quonfig/version.rb +5 -0
  38. data/lib/quonfig.rb +2 -1
  39. data/quonfig.gemspec +29 -165
  40. metadata +24 -193
  41. data/.claude/rules/constitution.md +0 -81
  42. data/.claude/rules/git-safety.md +0 -11
  43. data/.claude/rules/issue-tracking.md +0 -13
  44. data/.claude/rules/testing-workflow.md +0 -28
  45. data/.envrc.sample +0 -3
  46. data/.github/CODEOWNERS +0 -2
  47. data/.github/pull_request_template.md +0 -8
  48. data/.github/workflows/release.yml +0 -49
  49. data/.github/workflows/ruby.yml +0 -60
  50. data/.github/workflows/test.yaml +0 -40
  51. data/.rubocop.yml +0 -13
  52. data/.tool-versions +0 -1
  53. data/CLAUDE.md +0 -29
  54. data/CODEOWNERS +0 -1
  55. data/Gemfile +0 -26
  56. data/Gemfile.lock +0 -177
  57. data/Rakefile +0 -64
  58. data/VERSION +0 -1
  59. data/dev/allocation_stats +0 -60
  60. data/dev/benchmark +0 -40
  61. data/dev/console +0 -12
  62. data/dev/script_setup.rb +0 -18
  63. data/test/fixtures/datafile.json +0 -87
  64. data/test/integration/test_context_precedence.rb +0 -112
  65. data/test/integration/test_datadir_environment.rb +0 -54
  66. data/test/integration/test_dev_overrides.rb +0 -40
  67. data/test/integration/test_enabled.rb +0 -478
  68. data/test/integration/test_enabled_with_contexts.rb +0 -64
  69. data/test/integration/test_get.rb +0 -136
  70. data/test/integration/test_get_feature_flag.rb +0 -28
  71. data/test/integration/test_get_or_raise.rb +0 -60
  72. data/test/integration/test_get_weighted_values.rb +0 -34
  73. data/test/integration/test_helpers.rb +0 -667
  74. data/test/integration/test_helpers_test.rb +0 -73
  75. data/test/integration/test_post.rb +0 -44
  76. data/test/integration/test_telemetry.rb +0 -170
  77. data/test/support/common_helpers.rb +0 -106
  78. data/test/support/mock_base_client.rb +0 -27
  79. data/test/support/mock_config_loader.rb +0 -1
  80. data/test/test_bound_client.rb +0 -109
  81. data/test/test_caching_http_connection.rb +0 -218
  82. data/test/test_client.rb +0 -255
  83. data/test/test_client_network_mode.rb +0 -136
  84. data/test/test_client_telemetry.rb +0 -175
  85. data/test/test_config_loader.rb +0 -70
  86. data/test/test_context.rb +0 -139
  87. data/test/test_context_shape.rb +0 -37
  88. data/test/test_context_shape_aggregator.rb +0 -126
  89. data/test/test_datadir.rb +0 -203
  90. data/test/test_details_getters.rb +0 -242
  91. data/test/test_dev_context.rb +0 -163
  92. data/test/test_duration.rb +0 -37
  93. data/test/test_encryption.rb +0 -16
  94. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  95. data/test/test_evaluator.rb +0 -285
  96. data/test/test_example_contexts_aggregator.rb +0 -119
  97. data/test/test_exponential_backoff.rb +0 -44
  98. data/test/test_fixed_size_hash.rb +0 -119
  99. data/test/test_helper.rb +0 -17
  100. data/test/test_http_connection.rb +0 -81
  101. data/test/test_internal_logger.rb +0 -34
  102. data/test/test_options.rb +0 -198
  103. data/test/test_rate_limit_cache.rb +0 -44
  104. data/test/test_reason.rb +0 -79
  105. data/test/test_rename.rb +0 -65
  106. data/test/test_resolver.rb +0 -291
  107. data/test/test_semantic_logger_filter.rb +0 -144
  108. data/test/test_semver.rb +0 -108
  109. data/test/test_should_log.rb +0 -186
  110. data/test/test_sse_config_client.rb +0 -297
  111. data/test/test_stdlib_formatter.rb +0 -195
  112. data/test/test_telemetry_reporter.rb +0 -209
  113. data/test/test_typed_getters.rb +0 -131
  114. data/test/test_types.rb +0 -141
  115. data/test/test_weighted_value_resolver.rb +0 -84
@@ -1,667 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'quonfig'
5
-
6
- # Integration-test environment — the generated tests read these the same way
7
- # the SDK does at runtime. Mirrors sdk-node/test/integration/setup.ts and
8
- # sdk-go/internal/fixtures/test_helpers_test.go so behavior stays consistent
9
- # across SDKs.
10
- ENV['PREFAB_INTEGRATION_TEST_ENCRYPTION_KEY'] =
11
- 'c87ba22d8662282abe8a0e4651327b579cb64a454ab0f4c170b45b15f049a221'
12
- ENV['IS_A_NUMBER'] = '1234'
13
- ENV['NOT_A_NUMBER'] = 'not_a_number'
14
- ENV.delete('MISSING_ENV_VAR')
15
-
16
- # Shared fixture loader + resolver factory for the generated integration
17
- # tests in sdk-ruby/test/integration/test_*.rb (qfg-dk6.23/.24). The evaluator
18
- # wired up here still delegates to Quonfig::CriteriaEvaluator — once
19
- # qfg-dk6.10 ports the criterion operators to the JSON Criterion type,
20
- # generated tests will resolve end-to-end. Until then build_store simply
21
- # parses the JSON fixtures into the ConfigStore.
22
- module IntegrationTestHelpers
23
- DATA_DIR = File.expand_path(
24
- '../../../integration-test-data/data/integration-tests',
25
- __dir__
26
- )
27
- ENV_ID = 'Production'
28
- CONFIG_SUBDIRS = %w[configs feature-flags segments log-levels schemas].freeze
29
-
30
- def self.data_dir
31
- DATA_DIR
32
- end
33
-
34
- # fixture_name matches the generator's YAML suite name (e.g. 'get',
35
- # 'enabled'). Every suite shares the same config corpus — mirrors
36
- # sdk-node/sdk-go, which also build a single store for the whole run —
37
- # so the name is advisory. Accepting it keeps the call shape the task
38
- # spec asks for and leaves room for per-suite overlays later.
39
- def self.build_store(_fixture_name = nil)
40
- unless Dir.exist?(DATA_DIR)
41
- raise "[integration tests] fixtures not found at #{DATA_DIR} — " \
42
- 'clone quonfig/integration-test-data as a sibling of sdk-ruby.'
43
- end
44
-
45
- store = Quonfig::ConfigStore.new
46
- CONFIG_SUBDIRS.each do |subdir|
47
- dir = File.join(DATA_DIR, subdir)
48
- next unless Dir.exist?(dir)
49
-
50
- Dir.glob(File.join(dir, '*.json')).each do |path|
51
- raw = JSON.parse(File.read(path))
52
- cfg = to_config_response(raw)
53
- key = cfg[:key]
54
- next if key.nil? || key.empty?
55
-
56
- store.set(key, cfg)
57
- end
58
- end
59
- self.last_store = store
60
- store
61
- end
62
-
63
- def self.build_resolver(store)
64
- evaluator = Quonfig::Evaluator.new(store, env_id: ENV_ID)
65
- Quonfig::Resolver.new(store, evaluator)
66
- end
67
-
68
- # Resolve +key+ against +context+ and assert the unwrapped value (and,
69
- # when present, its reported value_type) match. Generated tests call
70
- # this for the "no default, no enabled" path. With the generator now
71
- # threading input.default through assert_get_with_default and routing
72
- # function: enabled cases through assert_enabled, this helper can stay
73
- # strict: missing keys still raise, non-bool actual stays non-bool.
74
- # Nil-expected cases (e.g. "get returns nil if value not found" with
75
- # on_no_default: 2) catch the resolver's MissingDefaultError and return nil.
76
- def self.assert_resolved(resolver, key, context, expected_value, expected_type = nil)
77
- ctx = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context || {})
78
- result =
79
- begin
80
- resolver.get(key, ctx)
81
- rescue Quonfig::Errors::MissingDefaultError
82
- nil
83
- end
84
- return expected_value if result.nil? && expected_value.nil?
85
-
86
- actual = if result.nil?
87
- nil
88
- elsif result.respond_to?(:unwrapped_value)
89
- result.unwrapped_value
90
- elsif result.respond_to?(:value)
91
- v = result.value
92
- v.respond_to?(:string) ? v.string : v
93
- else
94
- result
95
- end
96
-
97
- unless actual == expected_value
98
- raise Minitest::Assertion,
99
- "#{key}: expected #{expected_value.inspect} (#{expected_type}), got #{actual.inspect}"
100
- end
101
-
102
- if expected_type && result.respond_to?(:value_type)
103
- unless result.value_type.to_s == expected_type.to_s
104
- raise Minitest::Assertion,
105
- "#{key}: expected type #{expected_type}, got #{result.value_type}"
106
- end
107
- end
108
- actual
109
- end
110
-
111
- # function: enabled semantics — Quonfig::Client#enabled? returns the
112
- # bool value if the resolved value is a boolean, false otherwise.
113
- # The generator routes function: enabled cases through this helper so
114
- # the bool-coercion lives here, not inferred from the expected literal.
115
- def self.assert_enabled(resolver, key, context, expected_bool)
116
- ctx = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context || {})
117
- actual =
118
- begin
119
- result = resolver.get(key, ctx)
120
- if result.nil?
121
- false
122
- else
123
- v = result.respond_to?(:unwrapped_value) ? result.unwrapped_value : result
124
- (v == true || v == 'true') ? true : false
125
- end
126
- rescue Quonfig::Errors::MissingDefaultError
127
- false
128
- end
129
- unless actual == expected_bool
130
- raise Minitest::Assertion,
131
- "enabled?(#{key}): expected #{expected_bool.inspect}, got #{actual.inspect}"
132
- end
133
- actual
134
- end
135
-
136
- # input.default — thread the YAML default through the SDK's public
137
- # get(key, default) API. Build a Client over the same store the
138
- # resolver uses; that way we observe what the SDK actually returns
139
- # (default kicks in for missing keys, found-key wins over default).
140
- def self.assert_get_with_default(store, key, context, default_value, expected_value)
141
- # Build with environment: ENV_ID so config rules evaluate against the
142
- # 'Production' environment (matching what build_resolver does). Without
143
- # this the Client falls back to default rules.
144
- client = Quonfig::Client.new(store: store, environment: ENV_ID)
145
- ctx_arg =
146
- if context.nil? || (context.respond_to?(:empty?) && context.empty?)
147
- Quonfig::NO_DEFAULT_PROVIDED
148
- elsif context.is_a?(Quonfig::Context)
149
- context
150
- else
151
- Quonfig::Context.new(context)
152
- end
153
- actual = client.get(key, default_value, ctx_arg)
154
- unless actual == expected_value
155
- raise Minitest::Assertion,
156
- "#{key}: expected #{expected_value.inspect} (default=#{default_value.inspect}), got #{actual.inspect}"
157
- end
158
- actual
159
- end
160
-
161
- # Build a real Quonfig::Client whose initial fetch is intentionally slow
162
- # (an unreachable api_url + tiny init timeout) and assert that
163
- # Client#get raises Quonfig::Errors::InitializationTimeoutError.
164
- def self.assert_initialization_timeout_error(key, timeout_sec, api_url, on_init_failure)
165
- on_init = on_init_failure.to_s.sub(/\A:/, '').to_sym
166
- api_urls = api_url && !api_url.empty? ? [api_url] : ['https://127.0.0.1:1']
167
- client =
168
- begin
169
- Quonfig::Client.new(
170
- sdk_key: 'test-unused',
171
- api_urls: api_urls,
172
- initialization_timeout_sec: timeout_sec,
173
- on_init_failure: on_init,
174
- enable_sse: false,
175
- enable_polling: false
176
- )
177
- rescue Quonfig::Errors::InitializationTimeoutError
178
- return # construction itself raised — that's the expected outcome
179
- end
180
- raise Minitest::Assertion,
181
- 'expected Quonfig::Errors::InitializationTimeoutError to raise on get' \
182
- unless on_init == :raise
183
-
184
- begin
185
- client.get(key)
186
- raise Minitest::Assertion, "expected get(#{key}) to raise InitializationTimeoutError but it returned"
187
- rescue Quonfig::Errors::InitializationTimeoutError
188
- # success
189
- ensure
190
- client.respond_to?(:close) && client.close
191
- $logs = nil if defined?($logs)
192
- end
193
- end
194
-
195
- # Generic raise path through a real-client construction (e.g. on_init_failure
196
- # :return + missing_default on get_or_raise — init returns zero value,
197
- # then get_or_raise still raises MissingDefault). The function arg picks
198
- # the call shape: 'get_or_raise' uses get_or_raise(key); anything else
199
- # falls back to client.get(key).
200
- #
201
- # The Client logs a warning when init returns the zero value (the typical
202
- # on_init_failure: :return path). Drain $logs (if it exists from
203
- # CommonHelpers) so the test's teardown doesn't trip on it — that's the
204
- # whole point of the case.
205
- def self.assert_client_construction_raises(key, timeout_sec, api_url, on_init_failure, fn, err_class)
206
- on_init = on_init_failure.to_s.sub(/\A:/, '').to_sym
207
- api_urls = api_url && !api_url.empty? ? [api_url] : ['https://127.0.0.1:1']
208
- client = Quonfig::Client.new(
209
- sdk_key: 'test-unused',
210
- api_urls: api_urls,
211
- initialization_timeout_sec: timeout_sec,
212
- on_init_failure: on_init,
213
- enable_sse: false,
214
- enable_polling: false
215
- )
216
- begin
217
- if fn == 'get_or_raise' && client.respond_to?(:get_or_raise)
218
- client.get_or_raise(key)
219
- else
220
- # No public get_or_raise: call .get with no default and the SDK's
221
- # internal NO_DEFAULT_PROVIDED forces the missing-default raise.
222
- client.get(key)
223
- end
224
- raise Minitest::Assertion, "expected #{err_class} to raise but call returned"
225
- rescue err_class
226
- # success
227
- ensure
228
- client.respond_to?(:close) && client.close
229
- # Acknowledge the init-warning log so common_helpers' teardown won't
230
- # blow up. The warning IS the thing we asked for via :return policy.
231
- $logs = nil if defined?($logs)
232
- end
233
- end
234
-
235
- # Happy path through a real-client construction (rare; mostly here for
236
- # symmetry — the YAML init-timeout cases are all raise-path).
237
- def self.assert_client_construction_value(key, timeout_sec, api_url, on_init_failure, _fn, expected_value)
238
- on_init = on_init_failure.to_s.sub(/\A:/, '').to_sym
239
- api_urls = api_url && !api_url.empty? ? [api_url] : ['https://127.0.0.1:1']
240
- client = Quonfig::Client.new(
241
- sdk_key: 'test-unused',
242
- api_urls: api_urls,
243
- initialization_timeout_sec: timeout_sec,
244
- on_init_failure: on_init,
245
- enable_sse: false,
246
- enable_polling: false
247
- )
248
- actual = client.get(key)
249
- unless actual == expected_value
250
- raise Minitest::Assertion,
251
- "#{key}: expected #{expected_value.inspect}, got #{actual.inspect}"
252
- end
253
- client.respond_to?(:close) && client.close
254
- actual
255
- end
256
-
257
- # Temporarily set env vars for the duration of the block and restore the
258
- # originals (including absence) on exit — even if the block raises.
259
- def self.with_env(vars_hash)
260
- originals = {}
261
- vars_hash.each do |k, v|
262
- originals[k] = ENV[k]
263
- ENV[k] = v
264
- end
265
- yield
266
- ensure
267
- originals.each do |k, v|
268
- if v.nil?
269
- ENV.delete(k)
270
- else
271
- ENV[k] = v
272
- end
273
- end
274
- end
275
-
276
- # ----------------------------------------------------------------------
277
- # Aggregator helpers for the post.yaml + telemetry.yaml generated suites.
278
- # ----------------------------------------------------------------------
279
- #
280
- # The shared YAML in integration-test-data/tests/eval/{post,telemetry}.yaml
281
- # describes telemetry payloads in language-neutral terms — `aggregator:
282
- # context_shape | evaluation_summary | example_contexts` plus a `data`
283
- # block of inputs and an `expected_data` block of the would-be POST body.
284
- # The Ruby generator emits one method per case calling these three
285
- # helpers. They wire the YAML inputs through the real aggregator classes
286
- # (Quonfig::Telemetry::*) and translate the aggregator's drain_event
287
- # output into the YAML's snake_case schema for assertion.
288
- #
289
- # Recent build_store call stashes the Quonfig::ConfigStore on the module
290
- # so eval-summary cases can resolve real values for each key.
291
- class << self
292
- attr_accessor :last_store
293
- # Side-channel populated by record_one_eval whenever we redact a
294
- # confidential value before recording it on the aggregator. Keyed by
295
- # config_key → { unwrapped:, value_type: }. evaluation_summary_post
296
- # consults it so the YAML's `value` / `value_type` fields can still
297
- # assert the runtime resolved value while `selected_value` carries
298
- # the wire-redacted form.
299
- attr_accessor :last_unwrapped_overrides
300
- end
301
-
302
- # Construct an aggregator. +kind+ is one of :context_shape,
303
- # :evaluation_summary, :example_contexts (string or symbol). +overrides+
304
- # mirrors the YAML `client_overrides` block; the only options that
305
- # affect aggregator output today are `collect_evaluation_summaries`
306
- # (false → eval-summary aggregator created with max_keys=0 so it noops)
307
- # and `context_upload_mode` ("shape_only" / "none" → example-contexts
308
- # aggregator created with max=0; ":none" / ":shape_only" come through
309
- # as Ruby-symbol strings via js-yaml, which is why we strip the leading
310
- # colon defensively).
311
- def self.build_aggregator(kind, overrides = {})
312
- overrides = (overrides || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
313
- case normalize_kind(kind)
314
- when :context_shape
315
- max = aggregator_max_for(overrides, :context_shape)
316
- Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: max)
317
- when :evaluation_summary
318
- collect = overrides.fetch('collect_evaluation_summaries', true)
319
- max = collect ? 100_000 : 0
320
- Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max)
321
- when :example_contexts
322
- max = aggregator_max_for(overrides, :example_contexts)
323
- Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: max)
324
- else
325
- raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
326
- end
327
- end
328
-
329
- # Feed +data+ through +aggregator+ for the given +kind+. Each kind has
330
- # its own input shape (see post.yaml / telemetry.yaml):
331
- # - :context_shape → +data+ is a Hash of named contexts, OR an Array
332
- # of such hashes (multi-record case).
333
- # - :evaluation_summary
334
- # → +data+ is { 'keys' => [...], 'keys_without_context'
335
- # => [...] }. Each key is resolved against
336
- # +contexts+ (or empty contexts for the second
337
- # list), then the EvalResult is recorded.
338
- # - :example_contexts → same as :context_shape but recorded into the
339
- # example aggregator.
340
- def self.feed_aggregator(aggregator, kind, data, contexts: {})
341
- case normalize_kind(kind)
342
- when :context_shape
343
- each_context_record(data) { |rec| aggregator.push(rec) }
344
- when :example_contexts
345
- each_context_record(data) { |rec| aggregator.record(Quonfig::Context.new(rec)) }
346
- when :evaluation_summary
347
- record_eval_keys(aggregator, data, contexts)
348
- else
349
- raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
350
- end
351
- end
352
-
353
- # Drain the aggregator and assert its would-be POST body matches
354
- # +expected_data+. +endpoint+ is captured from YAML for diagnostics
355
- # (the Ruby helpers don't actually POST anything — the aggregator's
356
- # drain_event payload is what the reporter would ship). +expected_data+
357
- # is YAML-shaped (snake_case `field_types` etc.); we project the
358
- # aggregator's drain output into that shape so the comparison is
359
- # apples-to-apples.
360
- def self.assert_aggregator_post(aggregator, kind, expected_data, endpoint:)
361
- actual = build_actual_post(aggregator, kind)
362
-
363
- if expected_data.nil?
364
- unless actual.nil?
365
- raise Minitest::Assertion,
366
- "[#{endpoint}] expected no telemetry POST but aggregator produced #{actual.inspect}"
367
- end
368
- return
369
- end
370
-
371
- expected_normalized, actual_normalized = align_for_comparison(expected_data, actual, kind)
372
- actual_normalized = scrub_optional_fields(actual_normalized, expected_normalized, kind)
373
-
374
- unless actual_normalized == expected_normalized
375
- raise Minitest::Assertion,
376
- "[#{endpoint}] aggregator POST mismatch\n expected: #{expected_normalized.inspect}\n actual: #{actual_normalized.inspect}"
377
- end
378
- end
379
-
380
- # Normalize ordering on both sides for comparison. Telemetry payloads
381
- # are conceptually unordered sets — different SDKs (and different runs
382
- # within a single SDK if you swap a Hash for a different impl) emit
383
- # entries in different orders. Sort by a stable key so the comparison
384
- # is set-equality. Keeps assertion errors readable: both sides print in
385
- # the same canonical order.
386
- def self.align_for_comparison(expected, actual, kind)
387
- case normalize_kind(kind)
388
- when :evaluation_summary
389
- sort_key = ->(row) { [row['key'].to_s, (row.dig('summary', 'conditional_value_index') || 0)] }
390
- [expected.is_a?(Array) ? expected.sort_by(&sort_key) : expected,
391
- actual.is_a?(Array) ? actual.sort_by(&sort_key) : actual]
392
- when :context_shape
393
- sort_key = ->(row) { row['name'].to_s }
394
- [expected.is_a?(Array) ? expected.sort_by(&sort_key) : expected,
395
- actual.is_a?(Array) ? actual.sort_by(&sort_key) : actual]
396
- else
397
- [expected, actual]
398
- end
399
- end
400
- private_class_method :align_for_comparison
401
-
402
- # Drop fields from +actual+ that the YAML's +expected+ doesn't assert.
403
- # Today only `selected_value` in eval-summary rows is opt-in (some YAML
404
- # cases verify the proto-style wrapper, most don't). Index pairwise so
405
- # the per-row decision lines up.
406
- def self.scrub_optional_fields(actual, expected, kind)
407
- return actual unless normalize_kind(kind) == :evaluation_summary
408
- return actual unless actual.is_a?(Array) && expected.is_a?(Array)
409
-
410
- actual.each_with_index.map do |row, idx|
411
- exp_row = expected[idx]
412
- next row unless row.is_a?(Hash) && exp_row.is_a?(Hash)
413
- next row if exp_row.key?('selected_value')
414
-
415
- row.reject { |k, _| k == 'selected_value' }
416
- end
417
- end
418
- private_class_method :scrub_optional_fields
419
-
420
- # --- aggregator-helper internals ---
421
-
422
- def self.normalize_kind(kind)
423
- str = kind.to_s
424
- str = str.sub(/\A:/, '') # ":shape_only" → "shape_only" if a stray symbol-string sneaks in
425
- case str
426
- when 'context_shape' then :context_shape
427
- when 'evaluation_summary' then :evaluation_summary
428
- when 'example_contexts' then :example_contexts
429
- else raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
430
- end
431
- end
432
- private_class_method :normalize_kind
433
-
434
- # Strip a leading `:` so a Ruby-symbol-style YAML scalar (":shape_only"
435
- # → ":shape_only" string when js-yaml serializes it) compares cleanly.
436
- def self.strip_symbol(v)
437
- v.is_a?(String) ? v.sub(/\A:/, '') : v.to_s
438
- end
439
- private_class_method :strip_symbol
440
-
441
- def self.aggregator_max_for(overrides, agg_kind)
442
- mode = strip_symbol(overrides['context_upload_mode']) if overrides.key?('context_upload_mode')
443
- return 0 if mode == 'none'
444
-
445
- case agg_kind
446
- when :context_shape then 100_000
447
- when :example_contexts then mode == 'shape_only' ? 0 : 100_000
448
- end
449
- end
450
- private_class_method :aggregator_max_for
451
-
452
- def self.each_context_record(data)
453
- return if data.nil?
454
-
455
- if data.is_a?(Array)
456
- data.each { |row| yield row if row.is_a?(Hash) && !row.empty? }
457
- elsif data.is_a?(Hash)
458
- yield data unless data.empty?
459
- end
460
- end
461
- private_class_method :each_context_record
462
-
463
- def self.record_eval_keys(aggregator, data, contexts)
464
- return unless data.is_a?(Hash)
465
-
466
- keys = data['keys'] || data[:keys] || []
467
- keys_no_ctx = data['keys_without_context'] || data[:keys_without_context] || []
468
- store = last_store
469
- raise '[integration tests] no store cached — call build_store before feed_aggregator' if store.nil?
470
-
471
- resolver = build_resolver(store)
472
- ctx = contexts.is_a?(Quonfig::Context) ? contexts : Quonfig::Context.new(contexts || {})
473
- empty_ctx = Quonfig::Context.new({})
474
-
475
- self.last_unwrapped_overrides = {}
476
- Array(keys).each { |key| record_one_eval(aggregator, resolver, store, key, ctx) }
477
- Array(keys_no_ctx).each { |key| record_one_eval(aggregator, resolver, store, key, empty_ctx) }
478
- end
479
- private_class_method :record_eval_keys
480
-
481
- def self.record_one_eval(aggregator, resolver, store, key, ctx)
482
- cfg = store.get(key)
483
- return if cfg.nil?
484
-
485
- result =
486
- begin
487
- resolver.get(key, ctx)
488
- rescue Quonfig::Errors::MissingDefaultError
489
- nil
490
- end
491
- return if result.nil?
492
-
493
- # Confidential / decryptWith values must never appear in plaintext on
494
- # the wire. EvalResult#reportable_value, when populated, is the
495
- # `*****<md5>`-redacted substitute the resolver computed pre-decryption.
496
- # When we substitute, stash the runtime unwrapped value so the
497
- # post-projection can still assert YAML's `value` / `value_type` against
498
- # the resolved plaintext (the YAML treats `value` as the runtime view
499
- # and `selected_value` as the wire view).
500
- selected_for_telemetry = result.unwrapped_value
501
- if result.reportable_value
502
- selected_for_telemetry = result.reportable_value
503
- (self.last_unwrapped_overrides ||= {})[key] = {
504
- unwrapped: result.unwrapped_value,
505
- value_type: result.value_type
506
- }
507
- end
508
- aggregator.record(
509
- config_id: (cfg[:id] || cfg['id']).to_s,
510
- config_key: key,
511
- config_type: (cfg[:type] || cfg['type']).to_s,
512
- conditional_value_index: result.rule_index,
513
- weighted_value_index: result.weighted_value_index,
514
- selected_value: selected_for_telemetry,
515
- reason: result.wire_reason
516
- )
517
- end
518
- private_class_method :record_one_eval
519
-
520
- # Project +aggregator+'s drain_event payload onto the YAML's
521
- # snake_case `expected_data` schema. Returns nil when the aggregator
522
- # produced nothing (matches YAML's bare `expected_data:` lines).
523
- def self.build_actual_post(aggregator, kind)
524
- event = aggregator.drain_event
525
- return nil if event.nil?
526
-
527
- case normalize_kind(kind)
528
- when :context_shape then context_shape_post(event)
529
- when :evaluation_summary then evaluation_summary_post(event)
530
- when :example_contexts then example_contexts_post(event)
531
- end
532
- end
533
- private_class_method :build_actual_post
534
-
535
- def self.context_shape_post(event)
536
- shapes = event.dig('contextShapes', 'shapes') || []
537
- return nil if shapes.empty?
538
-
539
- shapes.map do |shape|
540
- { 'name' => shape['name'], 'field_types' => shape['fieldTypes'] }
541
- end
542
- end
543
- private_class_method :context_shape_post
544
-
545
- def self.example_contexts_post(event)
546
- examples = event.dig('exampleContexts', 'examples') || []
547
- return nil if examples.empty?
548
-
549
- # post.yaml expects a single context-set object (the first / only
550
- # example), keyed by named-context-name. Multiple examples are not
551
- # exercised in the YAML; if they ever are, this helper still picks
552
- # the first dedup'd record, matching sdk-node's wire shape.
553
- contexts = examples.first.dig('contextSet', 'contexts') || []
554
- contexts.each_with_object({}) do |ctx, acc|
555
- acc[ctx['type']] = ctx['values']
556
- end
557
- end
558
- private_class_method :example_contexts_post
559
-
560
- TYPE_LABELS = {
561
- 'config' => 'CONFIG',
562
- 'feature_flag' => 'FEATURE_FLAG',
563
- 'segment' => 'SEGMENT',
564
- 'log_level' => 'LOG_LEVEL',
565
- 'schema' => 'SCHEMA'
566
- }.freeze
567
- private_constant :TYPE_LABELS
568
-
569
- def self.evaluation_summary_post(event)
570
- summaries = event.dig('summaries', 'summaries') || []
571
- overrides = last_unwrapped_overrides || {}
572
- rows = []
573
- summaries.each do |summary|
574
- type_label = TYPE_LABELS[summary['type'].to_s] || summary['type'].to_s.upcase
575
- counters = summary['counters'] || []
576
- counters.each do |counter|
577
- selected = counter['selectedValue'] || {}
578
- unwrapped, value_type = unwrap_selected(selected)
579
- # When the resolver redacted this key (confidential / decryptWith),
580
- # selected_value carries the redacted form on the wire but YAML's
581
- # `value` / `value_type` should still reflect the runtime resolved
582
- # plaintext. Restore from the side channel populated in
583
- # record_one_eval.
584
- if (override = overrides[summary['key']])
585
- unwrapped = override[:unwrapped]
586
- value_type = override[:value_type] if override[:value_type]
587
- end
588
- row = {
589
- 'key' => summary['key'],
590
- 'type' => type_label,
591
- 'value' => unwrapped,
592
- 'value_type' => value_type,
593
- 'count' => counter['count'],
594
- 'reason' => counter['reason']
595
- }
596
- if counter.key?('selectedValue')
597
- # YAML test cases that assert `selected_value:` use the raw
598
- # tagged shape (e.g. {"string" => "hello.world"}). We always
599
- # emit it; the diff's expected_data will simply not include
600
- # the field for cases that don't care.
601
- row['selected_value'] = selected
602
- end
603
- summary_block = {
604
- 'config_row_index' => counter['configRowIndex'],
605
- 'conditional_value_index' => counter['conditionalValueIndex']
606
- }
607
- if counter.key?('weightedValueIndex')
608
- summary_block['weighted_value_index'] = counter['weightedValueIndex']
609
- end
610
- row['summary'] = summary_block
611
- rows << row
612
- end
613
- end
614
- return nil if rows.empty?
615
-
616
- # Strip selected_value from rows whose YAML cases don't assert it.
617
- # We can't know that here, so emit it conditionally based on the
618
- # caller. The simpler path is to only include selected_value when
619
- # the YAML case asks for it. To keep this stateless we drop it by
620
- # default; the helper re-adds it on request via opt-in (see
621
- # #with_selected_values below). Most cases assert without it.
622
- rows
623
- end
624
- private_class_method :evaluation_summary_post
625
-
626
- # Decode the proto-style selectedValue wrapper { "<type>" => <val> }
627
- # into [unwrapped_value, value_type_label]. Mirrors the keys used by
628
- # EvaluationSummariesAggregator#wrap_selected_value (bool/int/double/
629
- # string/stringList).
630
- def self.unwrap_selected(selected)
631
- return [nil, nil] unless selected.is_a?(Hash) && selected.size == 1
632
-
633
- key, value = selected.first
634
- case key
635
- when 'bool' then [value, 'bool']
636
- when 'int' then [value, 'int']
637
- when 'double' then [value, 'double']
638
- when 'string' then [value, 'string']
639
- when 'stringList' then [value, 'string_list']
640
- else [value, key]
641
- end
642
- end
643
- private_class_method :unwrap_selected
644
-
645
- # Normalize the raw JSON config on disk into the shape the rest of the
646
- # suite expects: one environment row for ENV_ID pulled out of the
647
- # top-level `environments` array. Matches sdk-node/setup.ts:toConfigResponse.
648
- def self.to_config_response(raw)
649
- environment = nil
650
- if raw['environments'].is_a?(Array)
651
- match = raw['environments'].find { |e| e.is_a?(Hash) && e['id'] == ENV_ID }
652
- environment = match if match
653
- end
654
-
655
- {
656
- id: raw['id'] || '',
657
- key: raw['key'],
658
- type: raw['type'],
659
- value_type: raw['valueType'],
660
- send_to_client_sdk: raw['sendToClientSdk'] || false,
661
- default: raw['default'] || { 'rules' => [] },
662
- environment: environment,
663
- raw: raw
664
- }
665
- end
666
- private_class_method :to_config_response
667
- end