ruact 0.0.1 → 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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- 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
|