ruact 0.0.2 → 0.0.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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. data/lib/tasks/rsc.rake +0 -9
@@ -1,5 +1,278 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../flight_wire_parser"
4
+
5
+ module Ruact
6
+ module Spec
7
+ # Internal helpers shared by the structural Flight matchers. Lives under
8
+ # `Ruact::Spec` (test-only namespace) — no top-level constants leak into
9
+ # the gem's public API surface.
10
+ # rubocop:disable Metrics/ClassLength -- single cohesive helper for matcher diffing/formatting; splitting would scatter related logic across files.
11
+ class FlightStructureDiff
12
+ # Keys the parser produces on every row. Predicates and expected rows
13
+ # may only reference these keys; unknown keys raise upfront so typos
14
+ # like `payloed:` don't silently match every row via `nil == nil`.
15
+ KNOWN_ROW_KEYS = %i[id class payload raw].freeze
16
+
17
+ # Keys that must be present in every expected-row hash passed to
18
+ # `match_flight_structure`. Excludes `:raw` because expected rows are
19
+ # authored as semantic descriptions, not byte-level snapshots.
20
+ REQUIRED_EXPECTED_KEYS = %i[id class payload].freeze
21
+
22
+ # Compute the set of differences between actual parsed rows and the
23
+ # expected structure. Import rows are matched as a multiset (their
24
+ # relative order among each other is not significant per AC1);
25
+ # everything else compares positionally.
26
+ def self.compute(actual_rows, expected_rows)
27
+ validate_expected_rows!(expected_rows)
28
+ return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length
29
+
30
+ diffs = []
31
+ pending_actual_imports = []
32
+ pending_expected_imports = []
33
+
34
+ actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
35
+ if actual[:class] == :import && expected[:class] == :import
36
+ pending_actual_imports << [i, actual]
37
+ pending_expected_imports << [i, expected]
38
+ next
39
+ end
40
+
41
+ next if rows_equal?(actual, expected)
42
+
43
+ diffs << build_field_diff(i, actual, expected)
44
+ end
45
+
46
+ diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
47
+ diffs.sort_by { |d| d[:idx] }
48
+ end
49
+
50
+ # Imports are an unordered multiset within their class (AC1). Sort both
51
+ # sides by `:id` (always unique per render) and any leftover diffs come
52
+ # from semantic mismatch in the same-position-after-sort pair.
53
+ def self.diff_imports_unordered(actual_pairs, expected_pairs)
54
+ return [] if actual_pairs.empty? && expected_pairs.empty?
55
+
56
+ sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
57
+ sorted_actual = actual_pairs.sort_by(&sort_key)
58
+ sorted_expected = expected_pairs.sort_by(&sort_key)
59
+
60
+ sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
61
+ next if rows_equal?(act_row, exp_row)
62
+
63
+ build_field_diff(act_idx, act_row, exp_row)
64
+ end
65
+ end
66
+
67
+ def self.diffs_for_length_mismatch(actual_rows, expected_rows)
68
+ diffs = []
69
+ [actual_rows.length, expected_rows.length].max.times do |i|
70
+ actual = actual_rows[i]
71
+ expected = expected_rows[i]
72
+ if actual.nil?
73
+ diffs << { kind: :missing, idx: i, expected_row: expected }
74
+ elsif expected.nil?
75
+ diffs << { kind: :extra, idx: i, actual_row: actual }
76
+ elsif !rows_equal?(actual, expected)
77
+ diffs << build_field_diff(i, actual, expected)
78
+ end
79
+ end
80
+ diffs
81
+ end
82
+
83
+ def self.rows_equal?(actual, expected)
84
+ REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
85
+ end
86
+
87
+ def self.build_field_diff(idx, actual, expected)
88
+ field_diff = nil
89
+ REQUIRED_EXPECTED_KEYS.each do |key|
90
+ next if actual[key] == expected[key]
91
+
92
+ path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
93
+ field_diff = { path: path, expected: sub_expected, got: sub_actual }
94
+ break
95
+ end
96
+
97
+ {
98
+ kind: :differs,
99
+ idx: idx,
100
+ row_class: actual[:class],
101
+ path: field_diff[:path],
102
+ expected: field_diff[:expected],
103
+ got: field_diff[:got],
104
+ expected_row: expected,
105
+ got_row: actual
106
+ }
107
+ end
108
+
109
+ # Walks parallel structures (Hash/Array/scalar) and returns the path,
110
+ # expected leaf, and actual leaf at the first differing position.
111
+ def self.first_difference(expected, actual, path)
112
+ return [path, expected, actual] if expected.class != actual.class
113
+ return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)
114
+
115
+ return diff_in_array(expected, actual, path) if expected.is_a?(Array)
116
+
117
+ diff_in_hash(expected, actual, path)
118
+ end
119
+
120
+ def self.diff_in_array(expected, actual, path)
121
+ [expected.length, actual.length].max.times do |i|
122
+ return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
123
+ next if expected[i] == actual[i]
124
+
125
+ return first_difference(expected[i], actual[i], "#{path}[#{i}]")
126
+ end
127
+ [path, expected, actual]
128
+ end
129
+
130
+ def self.diff_in_hash(expected, actual, path)
131
+ (expected.keys | actual.keys).each do |key|
132
+ return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
133
+ next if expected[key] == actual[key]
134
+
135
+ return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
136
+ end
137
+ [path, expected, actual]
138
+ end
139
+
140
+ # Validates each expected row carries the required `:id`, `:class`,
141
+ # `:payload` keys. Without this, an expected row of `{ id: 0, class:
142
+ # :model }` would silently pass against any actual row whose payload
143
+ # was nil — because the `==` check reads `expected[:payload]` as nil.
144
+ def self.validate_expected_rows!(expected_rows)
145
+ expected_rows.each_with_index do |row, i|
146
+ unless row.is_a?(Hash)
147
+ raise ArgumentError,
148
+ "match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
149
+ end
150
+
151
+ missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
152
+ next if missing.empty?
153
+
154
+ raise ArgumentError,
155
+ "match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
156
+ "Each expected row must include :id, :class, and :payload."
157
+ end
158
+ end
159
+
160
+ # Validates a predicate hash for `include_flight_row`. Unknown keys
161
+ # (typos like `payloed:` or `clas:`) raise immediately so they don't
162
+ # silently match any row via `row[:payloed]` returning nil.
163
+ def self.validate_predicate!(predicate)
164
+ unless predicate.is_a?(Hash)
165
+ raise ArgumentError,
166
+ "include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
167
+ end
168
+ raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?
169
+
170
+ unknown = predicate.keys - KNOWN_ROW_KEYS
171
+ return if unknown.empty?
172
+
173
+ raise ArgumentError,
174
+ "include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
175
+ "Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
176
+ end
177
+
178
+ def self.format_single(diff)
179
+ case diff[:kind]
180
+ when :missing
181
+ row = diff[:expected_row]
182
+ "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
183
+ when :extra
184
+ row = diff[:actual_row]
185
+ "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
186
+ else
187
+ format_field_diff(diff)
188
+ end
189
+ end
190
+
191
+ def self.format_field_diff(diff)
192
+ <<~MSG.strip
193
+ Expected Flight output to match structure.
194
+
195
+ Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
196
+ expected: #{diff[:expected].inspect}
197
+ got: #{diff[:got].inspect}
198
+
199
+ Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
200
+ expected: #{diff[:expected_row][:payload].inspect}
201
+ got: #{diff[:got_row][:payload].inspect}
202
+ MSG
203
+ end
204
+
205
+ # Builds the multi-row failure message: header naming the diff count,
206
+ # AC3-specified wording for missing / extra / differing rows, and a
207
+ # `Row N (<class>): ✓` summary line for every matching row so the
208
+ # reader can see what passed.
209
+ def self.format_multi(diffs, actual_rows, expected_rows)
210
+ header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
211
+ total = [actual_rows.length, expected_rows.length].max
212
+ diff_by_idx = diffs.to_h { |d| [d[:idx], d] }
213
+
214
+ body = (0...total).map do |i|
215
+ diff = diff_by_idx[i]
216
+ if diff
217
+ format_entry(diff)
218
+ else
219
+ row_class = (actual_rows[i] || expected_rows[i])[:class]
220
+ "Row #{i} (#{row_class}): ✓"
221
+ end
222
+ end
223
+
224
+ ([header, ""] + body).join("\n")
225
+ end
226
+
227
+ def self.format_entry(diff)
228
+ case diff[:kind]
229
+ when :missing
230
+ row = diff[:expected_row]
231
+ "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
232
+ when :extra
233
+ row = diff[:actual_row]
234
+ "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
235
+ else
236
+ <<~ENTRY.strip
237
+ Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
238
+ expected: #{diff[:expected].inspect}
239
+ got: #{diff[:got].inspect}
240
+ ENTRY
241
+ end
242
+ end
243
+
244
+ # Subset / case-equality match used by `include_flight_row`. Plain
245
+ # values use `==`; richer matchers (`hash_including`, `array_including`,
246
+ # `kind_of`, regexes) use `===` which delegates to their custom logic.
247
+ # Predicate keys are validated upfront by `validate_predicate!`, so
248
+ # this method can assume every key is one of the known row keys.
249
+ def self.row_matches?(row, predicate)
250
+ predicate.all? do |key, expected_value|
251
+ actual_value = row[key]
252
+ case expected_value
253
+ when Symbol, Numeric, NilClass, TrueClass, FalseClass
254
+ expected_value == actual_value
255
+ else
256
+ # rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
257
+ # (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
258
+ expected_value === actual_value
259
+ # rubocop:enable Style/CaseEquality
260
+ end
261
+ end
262
+ end
263
+ end
264
+ # rubocop:enable Metrics/ClassLength
265
+ end
266
+ end
267
+
268
+ # `match_flight_fixture(name)` — Phase 1 byte-exact snapshot matcher.
269
+ #
270
+ # Reads `spec/fixtures/flight/<name>.txt` and compares the actual wire bytes
271
+ # to the file contents via `==`. Used when the wire format itself is the
272
+ # contract (escape rules, ordering invariants, payload framing).
273
+ #
274
+ # @example
275
+ # expect(serializer.serialize_value("$danger")).to match_flight_fixture("string_dollar_escape")
3
276
  RSpec::Matchers.define :match_flight_fixture do |name|
4
277
  match do |actual|
5
278
  fixtures_dir = File.expand_path("../../fixtures/flight", __dir__)
@@ -23,3 +296,86 @@ RSpec::Matchers.define :match_flight_fixture do |name|
23
296
  "match Flight wire fixture '#{name}'"
24
297
  end
25
298
  end
299
+
300
+ # `match_flight_structure(expected)` — Phase 2 structural matcher (Story 7.5).
301
+ #
302
+ # Parses the actual Flight wire output via `Ruact::Spec::FlightWireParser`,
303
+ # then compares the resulting array of row records against `expected`. Hash
304
+ # payloads are compared structurally (key insertion order is ignored), so
305
+ # cosmetic JSON re-ordering does not break specs that assert on semantics.
306
+ # Import rows are matched as a multiset; non-import rows are compared
307
+ # positionally because Flight semantics depend on their order.
308
+ #
309
+ # Failure messages name the row index and the differing field — bytes are
310
+ # only printed when no narrower diff is available.
311
+ #
312
+ # @example
313
+ # expect(wire).to match_flight_structure([
314
+ # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]] },
315
+ # { id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
316
+ # ])
317
+ RSpec::Matchers.define :match_flight_structure do |expected|
318
+ match do |actual|
319
+ @parsed = Ruact::Spec::FlightWireParser.parse(actual)
320
+ @expected_rows = expected
321
+ @diffs = Ruact::Spec::FlightStructureDiff.compute(@parsed, expected)
322
+ @diffs.empty?
323
+ end
324
+
325
+ failure_message do |_actual|
326
+ if @diffs.length == 1
327
+ Ruact::Spec::FlightStructureDiff.format_single(@diffs.first)
328
+ else
329
+ Ruact::Spec::FlightStructureDiff.format_multi(@diffs, @parsed, @expected_rows)
330
+ end
331
+ end
332
+
333
+ failure_message_when_negated do |_actual|
334
+ "Expected output NOT to match the given Flight wire structure, but it did."
335
+ end
336
+
337
+ description do
338
+ "match Flight wire structure (#{expected.length} row(s))"
339
+ end
340
+ end
341
+
342
+ # `include_flight_row(predicate)` — Phase 2 ordering-independent presence matcher (Story 7.5).
343
+ #
344
+ # Parses the actual Flight wire output, then asserts at least one parsed row
345
+ # satisfies the predicate hash. Subset semantics: only the keys present in
346
+ # `predicate` are compared. Predicate keys must be one of `:id`, `:class`,
347
+ # `:payload`, `:raw` — unknown keys raise upfront so typos don't silently
348
+ # match every row. Values may be plain Ruby values (compared with `==`) or
349
+ # RSpec argument matchers like `hash_including(...)` (compared with `===`).
350
+ # Negation (`not_to`) is supported.
351
+ #
352
+ # @example
353
+ # expect(wire).to include_flight_row(class: :model, payload: hash_including("postId" => 42))
354
+ RSpec::Matchers.define :include_flight_row do |predicate|
355
+ match do |actual|
356
+ Ruact::Spec::FlightStructureDiff.validate_predicate!(predicate)
357
+ @parsed = Ruact::Spec::FlightWireParser.parse(actual)
358
+ @predicate = predicate
359
+ @matched_idx = @parsed.find_index { |row| Ruact::Spec::FlightStructureDiff.row_matches?(row, predicate) }
360
+ !@matched_idx.nil?
361
+ end
362
+
363
+ failure_message do |_actual|
364
+ summary = @parsed.each_with_index.map do |row, i|
365
+ payload_snippet = row[:payload].inspect
366
+ payload_snippet = "#{payload_snippet[0, 80]}…" if payload_snippet.length > 80
367
+ " [#{i}] id=#{row[:id].inspect}, class=#{row[:class]}, payload=#{payload_snippet}"
368
+ end.join("\n")
369
+ summary = " (no rows parsed)" if @parsed.empty?
370
+
371
+ "Expected Flight output to include a row matching: #{predicate.inspect}.\nParsed rows:\n#{summary}"
372
+ end
373
+
374
+ failure_message_when_negated do |_actual|
375
+ "Expected Flight output NOT to include a row matching: #{predicate.inspect}, but row #{@matched_idx} matched."
376
+ end
377
+
378
+ description do
379
+ "include a Flight row matching #{predicate.inspect}"
380
+ end
381
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Specs for the three Flight wire matcher modes added in Story 7.5
6
+ # (`match_flight_fixture`, `match_flight_structure`, `include_flight_row`).
7
+ RSpec.describe "Flight wire matchers" do
8
+ # Captures an `ExpectationNotMetError` raised by the inner block so the
9
+ # spec can make multiple assertions on its message without resorting to a
10
+ # multi-line block chained off `raise_error`.
11
+ def capture_failure
12
+ yield
13
+ nil
14
+ rescue RSpec::Expectations::ExpectationNotMetError => e
15
+ e
16
+ end
17
+
18
+ describe "match_flight_fixture (existing snapshot mode)" do
19
+ let(:nil_wire) { "0:null\n" }
20
+
21
+ it "passes against canonical fixture content (regression check)" do
22
+ expect(nil_wire).to match_flight_fixture("nil")
23
+ end
24
+ end
25
+
26
+ describe "match_flight_structure" do
27
+ let(:simple_wire) { %(0:{"className":"box"}\n) }
28
+ let(:two_row_wire) { %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n) }
29
+
30
+ it "passes when the actual wire matches a single-row expected structure" do
31
+ expect(simple_wire).to match_flight_structure([
32
+ { id: 0, class: :model, payload: { "className" => "box" } }
33
+ ])
34
+ end
35
+
36
+ it "passes for a two-row mixed import + model sequence" do
37
+ expect(two_row_wire).to match_flight_structure([
38
+ { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]] },
39
+ { id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
40
+ ])
41
+ end
42
+
43
+ it "fails with a missing-row message when actual has fewer rows than expected" do
44
+ err = capture_failure do
45
+ expect(simple_wire).to match_flight_structure([
46
+ { id: 0, class: :model, payload: { "className" => "box" } },
47
+ { id: 1, class: :import, payload: [] }
48
+ ])
49
+ end
50
+
51
+ expect(err.message).to include("Expected row 1 (import) was not produced.")
52
+ expect(err.message).to include("expected: {")
53
+ end
54
+
55
+ it "produces the AC3 verbatim row-indexed diff for a single-field semantic regression" do
56
+ broken_wire = %(0:["$X","div",null,{"className":"box","children":"hi"}]\n)
57
+ expected_payload = ["$", "div", nil, { "className" => "box", "children" => "hi" }]
58
+ got_payload = ["$X", "div", nil, { "className" => "box", "children" => "hi" }]
59
+ expected_structure = [{ id: 0, class: :model, payload: expected_payload }]
60
+
61
+ # Hash#inspect changed between Ruby 3.3 (`{"a"=>"b"}`) and Ruby 3.4
62
+ # (`{"a" => "b"}`). The AC3 contract is "values shown via .inspect" —
63
+ # so we render the expected message with the same .inspect the matcher
64
+ # uses at runtime, keeping the spec stable across the CI Ruby matrix.
65
+ expected_message = <<~MSG.strip
66
+ Expected Flight output to match structure.
67
+
68
+ Row 0 (model) differs at .payload[0]:
69
+ expected: "$"
70
+ got: "$X"
71
+
72
+ Row 0 (model) full diff:
73
+ expected: #{expected_payload.inspect}
74
+ got: #{got_payload.inspect}
75
+ MSG
76
+
77
+ expect do
78
+ expect(broken_wire).to match_flight_structure(expected_structure)
79
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError, expected_message)
80
+ end
81
+
82
+ it "fails with an unexpected-row message when actual has extra rows" do
83
+ err = capture_failure do
84
+ expect(two_row_wire).to match_flight_structure([
85
+ { id: 1, class: :import,
86
+ payload: ["/L.jsx", "L", ["/L.jsx"]] }
87
+ ])
88
+ end
89
+
90
+ expect(err.message).to include("Got unexpected row 1 (model)")
91
+ end
92
+
93
+ it "tolerates cosmetic JSON key reordering in payload hashes (AC4)" do
94
+ canonical = %(0:{"a":1,"b":2}\n)
95
+ perturbed = %(0:{"b":2,"a":1}\n)
96
+ expected_structure = [{ id: 0, class: :model, payload: { "a" => 1, "b" => 2 } }]
97
+
98
+ expect(canonical).to match_flight_structure(expected_structure)
99
+ expect(perturbed).to match_flight_structure(expected_structure)
100
+ end
101
+
102
+ it "passes negation when the structure does not match" do
103
+ expect(simple_wire).not_to match_flight_structure([
104
+ { id: 0, class: :model, payload: { "className" => "circle" } }
105
+ ])
106
+ end
107
+
108
+ # AC1: "multiple I rows are an unordered set". The expected list below
109
+ # reverses the import order vs the wire — the structural matcher must
110
+ # still consider this a match because import-row ordering is not
111
+ # protocol-significant within the import class.
112
+ it "treats import rows as an unordered set (AC1)" do
113
+ wire = %(1:I["/A.jsx","A",["/A.jsx"]]\n2:I["/B.jsx","B",["/B.jsx"]]\n0:["$","$L1",null,{}]\n)
114
+
115
+ expect(wire).to match_flight_structure([
116
+ { id: 2, class: :import, payload: ["/B.jsx", "B", ["/B.jsx"]] },
117
+ { id: 1, class: :import, payload: ["/A.jsx", "A", ["/A.jsx"]] },
118
+ { id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
119
+ ])
120
+ end
121
+
122
+ # Defends against incomplete expected rows silently passing when actual
123
+ # payload happens to be nil (e.g. `{ id: 0, class: :model }` without
124
+ # `:payload` would otherwise satisfy any row whose payload is nil).
125
+ it "raises ArgumentError when an expected row is missing :payload" do
126
+ expect do
127
+ expect(simple_wire).to match_flight_structure([{ id: 0, class: :model }])
128
+ end.to raise_error(ArgumentError, /missing required keys.*:payload/)
129
+ end
130
+
131
+ it "raises ArgumentError when an expected row is missing :class" do
132
+ expect do
133
+ expect(simple_wire).to match_flight_structure([{ id: 0, payload: {} }])
134
+ end.to raise_error(ArgumentError, /missing required keys.*:class/)
135
+ end
136
+
137
+ # Multi-row failure message: the count, AC3 wording for missing/extra,
138
+ # plus a "✓" line for every matching row so the reader can confirm
139
+ # which rows passed (AC3 — "Other rows that match are summarized as
140
+ # `Row N (<class>): ✓`").
141
+ it "shows matching-row checkmarks alongside multi-row diffs" do
142
+ wire = %(1:I["/A.jsx","A",["/A.jsx"]]\n0:["$X","div",null,{}]\n)
143
+
144
+ err = capture_failure do
145
+ expect(wire).to match_flight_structure([
146
+ { id: 1, class: :import, payload: ["/A.jsx", "A", ["/A.jsx"]] },
147
+ { id: 0, class: :model, payload: ["$", "div", nil, {}] },
148
+ { id: 2, class: :model, payload: ["$", "span", nil, {}] }
149
+ ])
150
+ end
151
+
152
+ expect(err.message).to include("Expected Flight output to match structure. 2 rows differ:")
153
+ expect(err.message).to include("Row 0 (import): ✓")
154
+ expect(err.message).to include("Row 1 (model) differs at .payload[0]:")
155
+ expect(err.message).to include("Expected row 2 (model) was not produced.")
156
+ end
157
+ end
158
+
159
+ describe "include_flight_row" do
160
+ let(:wire_with_post_id) do
161
+ %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{"postId":42}]\n)
162
+ end
163
+
164
+ it "matches when at least one row satisfies a hash_including payload predicate" do
165
+ expect(wire_with_post_id).to include_flight_row(
166
+ class: :model,
167
+ payload: include("postId" => 42)
168
+ )
169
+ end
170
+
171
+ it "fails listing parsed rows when no row matches the predicate" do
172
+ err = capture_failure do
173
+ expect(wire_with_post_id).to include_flight_row(
174
+ class: :model,
175
+ payload: include("postId" => 999)
176
+ )
177
+ end
178
+
179
+ expect(err.message).to include("Expected Flight output to include a row matching")
180
+ expect(err.message).to include("[0] id=1, class=import")
181
+ expect(err.message).to include("[1] id=0, class=model")
182
+ end
183
+
184
+ it "supports negation with not_to" do
185
+ expect(wire_with_post_id).not_to include_flight_row(class: :error)
186
+ end
187
+
188
+ it "fails negation when a row matches, naming the offending row index" do
189
+ expect do
190
+ expect(wire_with_post_id).not_to include_flight_row(class: :import)
191
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /but row 0 matched/)
192
+ end
193
+
194
+ it "supports array_including for the payload key" do
195
+ expect(wire_with_post_id).to include_flight_row(
196
+ class: :import,
197
+ payload: include("/L.jsx")
198
+ )
199
+ end
200
+
201
+ # AC4 fixture-mode failure proof. The structural matcher tolerates the
202
+ # cosmetic perturbation; the fixture matcher fails *positively* with the
203
+ # expected-vs-got diff visible — confirming fixture mode is the wire-
204
+ # format contract guard. This spec verifies the failure message rather
205
+ # than relying on a `not_to` shortcut (which would prove only that the
206
+ # matcher returned false, not that the failure is loud and informative).
207
+ it "demonstrates cosmetic-vs-fixture asymmetry — structure tolerates re-ordering, fixture fails loudly (AC4)" do
208
+ canonical_wire = %(0:{"debug":true,"count":5,"label":"x"}\n)
209
+ perturbed_wire = %(0:{"label":"x","count":5,"debug":true}\n)
210
+ expected_structure = [
211
+ { id: 0, class: :model, payload: { "debug" => true, "count" => 5, "label" => "x" } }
212
+ ]
213
+
214
+ err = capture_failure do
215
+ expect(perturbed_wire).to match_flight_fixture("hash")
216
+ end
217
+
218
+ aggregate_failures do
219
+ # Structural mode: both pass — JSON key reordering is cosmetic.
220
+ expect(canonical_wire).to match_flight_structure(expected_structure)
221
+ expect(perturbed_wire).to match_flight_structure(expected_structure)
222
+
223
+ # Fixture mode: canonical passes — the fixture file is the canonical
224
+ # wire bytes.
225
+ expect(canonical_wire).to match_flight_fixture("hash")
226
+
227
+ # Fixture mode against the perturbed wire fails *loudly* with the
228
+ # bytes-for-bytes diff so a human reviewer can see the cosmetic drift.
229
+ expect(err).to be_a(RSpec::Expectations::ExpectationNotMetError)
230
+ expect(err.message).to include("Expected output to match fixture at", "hash.txt", "Expected:", "Got:")
231
+ expect(err.message).to include(perturbed_wire.inspect)
232
+ end
233
+ end
234
+
235
+ # Predicate validation: an unknown key (typo) must raise immediately.
236
+ # Otherwise `row[:payloed]` returns nil and `nil == nil` would silently
237
+ # match every row, hiding broken specs.
238
+ it "raises ArgumentError when the predicate has an unknown key" do
239
+ expect do
240
+ expect(wire_with_post_id).to include_flight_row(payloed: { "postId" => 42 })
241
+ end.to raise_error(ArgumentError, /unknown keys.*:payloed/)
242
+ end
243
+
244
+ it "raises ArgumentError when given an empty predicate" do
245
+ expect do
246
+ expect(wire_with_post_id).to include_flight_row({})
247
+ end.to raise_error(ArgumentError, /predicate cannot be empty/)
248
+ end
249
+ end
250
+ end