ruact 0.0.2 → 0.0.4

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 (131) 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 +88 -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 +1779 -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 +100 -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 +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require "json"
5
+
6
+ module Ruact
7
+ # Test-support utilities. Code under `Ruact::Spec` is consumed only by the
8
+ # gem's own RSpec suite; it is not part of the public API and may change
9
+ # shape across stories without a deprecation cycle.
10
+ module Spec
11
+ # Raised when {FlightWireParser.parse} encounters input it cannot decode.
12
+ # The message names the byte offset of the unparseable row so the spec
13
+ # author can locate the problem in a printed wire string.
14
+ class FlightWireParseError < StandardError; end
15
+
16
+ # Parses a Flight wire byte string into an ordered array of row records.
17
+ #
18
+ # Used by the structural Flight RSpec matchers
19
+ # (`match_flight_structure` / `include_flight_row`) to assert on parsed
20
+ # semantics rather than literal bytes. Pure function — no I/O, no global
21
+ # state, no `Thread.current`.
22
+ #
23
+ # @example
24
+ # wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
25
+ # Ruact::Spec::FlightWireParser.parse(wire)
26
+ # # => [
27
+ # # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
28
+ # # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
29
+ # # ]
30
+ class FlightWireParser
31
+ # Parse a complete Flight wire byte string.
32
+ #
33
+ # @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
34
+ # @return [Array<Hash>] one hash per row, in wire order. See class docs
35
+ # for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
36
+ # @raise [Ruact::Spec::FlightWireParseError] when a row is malformed.
37
+ def self.parse(wire)
38
+ rows = []
39
+ scanner = StringScanner.new(wire)
40
+
41
+ until scanner.eos?
42
+ start_offset = scanner.pos
43
+ rows << parse_row(scanner, start_offset)
44
+ end
45
+
46
+ rows
47
+ end
48
+
49
+ def self.parse_row(scanner, start_offset)
50
+ # Hint rows have no ID: ":H<code><json>\n"
51
+ if scanner.peek(2) == ":H"
52
+ scanner.pos += 2
53
+ code = scanner.getch
54
+ raise_parse_error(start_offset, "missing hint code char") if code.nil?
55
+
56
+ json = read_to_newline(scanner, start_offset)
57
+ return {
58
+ id: nil,
59
+ class: :hint,
60
+ payload: [code, parse_json(json, start_offset)],
61
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
62
+ }
63
+ end
64
+
65
+ hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
66
+ scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
67
+ id = hex.to_i(16)
68
+
69
+ case scanner.peek(1)
70
+ when "I" then parse_tagged(:import, scanner, id, start_offset)
71
+ when "T" then parse_text_row(scanner, id, start_offset)
72
+ when "E" then parse_tagged(:error, scanner, id, start_offset)
73
+ else parse_model_row(scanner, id, start_offset)
74
+ end
75
+ end
76
+
77
+ def self.parse_tagged(klass, scanner, id, start_offset)
78
+ scanner.getch # consume the tag byte (I or E)
79
+ json = read_to_newline(scanner, start_offset)
80
+ {
81
+ id: id,
82
+ class: klass,
83
+ payload: parse_json(json, start_offset),
84
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
85
+ }
86
+ end
87
+
88
+ def self.parse_model_row(scanner, id, start_offset)
89
+ json = read_to_newline(scanner, start_offset)
90
+ {
91
+ id: id,
92
+ class: :model,
93
+ payload: parse_json(json, start_offset),
94
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
95
+ }
96
+ end
97
+
98
+ def self.parse_text_row(scanner, id, start_offset)
99
+ scanner.getch # consume "T"
100
+ len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
101
+ scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
102
+ len = len_hex.to_i(16)
103
+
104
+ text = scanner.peek(len)
105
+ raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
106
+
107
+ scanner.pos += len
108
+ {
109
+ id: id,
110
+ class: :text,
111
+ payload: text,
112
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
113
+ }
114
+ end
115
+
116
+ def self.read_to_newline(scanner, start_offset)
117
+ line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
118
+ line.chomp
119
+ end
120
+
121
+ def self.parse_json(str, offset)
122
+ JSON.parse(str)
123
+ rescue JSON::ParserError => e
124
+ raise_parse_error(offset, "invalid JSON: #{e.message}")
125
+ end
126
+
127
+ def self.raise_parse_error(offset, reason)
128
+ raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
129
+ end
130
+
131
+ private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
132
+ :read_to_newline, :parse_json, :raise_parse_error
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "flight_wire_parser"
5
+
6
+ module Ruact
7
+ module Spec
8
+ RSpec.describe FlightWireParser do
9
+ describe ".parse" do
10
+ it "parses a single model row" do
11
+ rows = described_class.parse(%(0:{"className":"box"}\n))
12
+
13
+ expect(rows).to eq([
14
+ {
15
+ id: 0,
16
+ class: :model,
17
+ payload: { "className" => "box" },
18
+ raw: %(0:{"className":"box"}\n)
19
+ }
20
+ ])
21
+ end
22
+
23
+ it "parses a single import row and decodes the hex id" do
24
+ rows = described_class.parse(%(a:I["/L.jsx","L",["/L.jsx"]]\n))
25
+
26
+ expect(rows.length).to eq(1)
27
+ expect(rows.first).to include(
28
+ id: 10,
29
+ class: :import,
30
+ payload: ["/L.jsx", "L", ["/L.jsx"]]
31
+ )
32
+ end
33
+
34
+ it "parses a single error row" do
35
+ rows = described_class.parse(%(2:E{"message":"boom"}\n))
36
+
37
+ expect(rows.first).to include(
38
+ id: 2,
39
+ class: :error,
40
+ payload: { "message" => "boom" }
41
+ )
42
+ end
43
+
44
+ it "parses a hint row with no id" do
45
+ rows = described_class.parse(%(:HL"/preload.js"\n))
46
+
47
+ expect(rows.first).to include(
48
+ id: nil,
49
+ class: :hint,
50
+ payload: ["L", "/preload.js"]
51
+ )
52
+ end
53
+
54
+ it "parses a T row of exactly LARGE_TEXT_THRESHOLD bytes followed by a model row that references it" do
55
+ large_text = "a" * 1024
56
+ wire = "1:T#{1024.to_s(16)},#{large_text}0:\"$T1\"\n"
57
+
58
+ rows = described_class.parse(wire)
59
+
60
+ expect(rows.length).to eq(2)
61
+ expect(rows[0]).to include(id: 1, class: :text, payload: large_text)
62
+ expect(rows[1]).to include(id: 0, class: :model, payload: "$T1")
63
+ end
64
+
65
+ it "parses a mixed-row sequence preserving wire order" do
66
+ wire = %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n)
67
+
68
+ rows = described_class.parse(wire)
69
+
70
+ expect(rows.map { |r| [r[:id], r[:class]] }).to eq([[1, :import], [0, :model]])
71
+ end
72
+
73
+ it "parses empty input as an empty array" do
74
+ expect(described_class.parse("")).to eq([])
75
+ end
76
+
77
+ it "raises FlightWireParseError naming the byte offset on malformed JSON" do
78
+ wire = %(0:{not-json}\n)
79
+
80
+ expect { described_class.parse(wire) }
81
+ .to raise_error(FlightWireParseError, /cannot parse row at offset \d+/)
82
+ end
83
+
84
+ it "raises FlightWireParseError when a T row is truncated" do
85
+ wire = "0:T#{10.to_s(16)},abc" # claims 16 bytes but provides 3
86
+
87
+ expect { described_class.parse(wire) }
88
+ .to raise_error(FlightWireParseError, /cannot parse row at offset 0: T row truncated/)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -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