ruact 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +9 -0
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +113 -0
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +2 -2
  11. data/lib/ruact/serializable.rb +98 -6
  12. data/lib/ruact/server.rb +163 -0
  13. data/lib/ruact/server_functions/introspection.rb +81 -0
  14. data/lib/ruact/server_functions.rb +26 -4
  15. data/lib/ruact/testing/component_query.rb +113 -0
  16. data/lib/ruact/testing/flight_extractor.rb +221 -0
  17. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  18. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  19. data/lib/ruact/testing.rb +90 -0
  20. data/lib/ruact/version.rb +1 -1
  21. data/lib/tasks/ruact.rake +55 -2
  22. data/spec/ruact/doctor_spec.rb +141 -0
  23. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  24. data/spec/ruact/install_generator_spec.rb +285 -0
  25. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  26. data/spec/ruact/serializable_spec.rb +126 -0
  27. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  28. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  29. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  30. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  31. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  32. data/spec/support/flight_wire_parser.rb +12 -126
  33. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  34. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  35. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  36. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  38. metadata +14 -2
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact/testing"
5
+
6
+ # Story 15.4 (FR108) — end-to-end specs for the PUBLIC render-assertion matcher
7
+ # `have_ruact_component`. Every example asserts against a REAL Flight wire body
8
+ # produced by `Ruact::RenderPipeline` (the same serializer the controller runs),
9
+ # not a hand-authored fixture — so import rows, `$L<id>` references, and the
10
+ # serialized props hash are exactly what a host would receive.
11
+ module Ruact
12
+ RSpec.describe "have_ruact_component matcher", :story_15_4 do
13
+ let(:manifest) do
14
+ ClientManifest.from_hash({
15
+ "LikeButton" => {
16
+ "id" => "/assets/LikeButton-abc.js",
17
+ "name" => "LikeButton",
18
+ "chunks" => ["/assets/LikeButton-abc.js"]
19
+ },
20
+ "PostCard" => {
21
+ "id" => "/components/PostCard.jsx",
22
+ "name" => "PostCard",
23
+ "chunks" => ["/components/PostCard.jsx"]
24
+ }
25
+ })
26
+ end
27
+
28
+ let(:pipeline) { RenderPipeline.new(manifest) }
29
+
30
+ # Renders an ERB snippet to a REAL Flight wire string.
31
+ def render_wire(erb_source, **ivars)
32
+ ctx = Object.new
33
+ ivars.each { |k, v| ctx.instance_variable_set("@#{k}", v) }
34
+ pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
35
+ end
36
+
37
+ # Wraps a wire in the REAL HTML shell the gem emits (`__FLIGHT_DATA` push),
38
+ # via the shipped `Ruact::ViewHelper#ruact_flight_data_script`.
39
+ def html_shell(wire)
40
+ emitter = Object.new.extend(Ruact::ViewHelper)
41
+ script = emitter.send(:ruact_flight_data_script, wire)
42
+ "<!DOCTYPE html><html><body><div id=\"root\"></div>#{script}</body></html>"
43
+ end
44
+
45
+ # A minimal duck-typed response object exposing `.body` (+ optional
46
+ # `.content_type`), like an ActionDispatch/Rack response.
47
+ def response_double(body, content_type: nil)
48
+ Struct.new(:body, :content_type).new(body, content_type)
49
+ end
50
+
51
+ describe "presence (raw text/x-component body)" do
52
+ it "passes when the named component was rendered" do
53
+ wire = render_wire("<LikeButton postId={42} />")
54
+ expect(wire).to have_ruact_component("LikeButton")
55
+ end
56
+
57
+ it "resolves the name by module basename too (D3)" do
58
+ wire = render_wire("<PostCard title={\"Hi\"} />")
59
+ # Manifest module path is /components/PostCard.jsx → basename PostCard.
60
+ expect(wire).to have_ruact_component("PostCard")
61
+ end
62
+
63
+ it "fails with a legible message naming what was found when absent" do
64
+ wire = render_wire("<LikeButton />")
65
+ expect do
66
+ expect(wire).to have_ruact_component("Missing")
67
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError,
68
+ /component named "Missing".*found.*LikeButton/m)
69
+ end
70
+
71
+ it "supports negation for an absent component" do
72
+ wire = render_wire("<LikeButton />")
73
+ expect(wire).not_to have_ruact_component("Missing")
74
+ end
75
+
76
+ it "negation fails legibly when the component IS present" do
77
+ wire = render_wire("<LikeButton />")
78
+ expect do
79
+ expect(wire).not_to have_ruact_component("LikeButton")
80
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /NOT to have rendered.*"LikeButton"/m)
81
+ end
82
+ end
83
+
84
+ describe ".with_props (serialized wire form, D4)" do
85
+ it "passes when props satisfy a hash_including matcher" do
86
+ wire = render_wire("<LikeButton postId={42} />")
87
+ expect(wire).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 42))
88
+ end
89
+
90
+ it "passes with a bare key-presence include matcher" do
91
+ wire = render_wire("<LikeButton postId={42} />")
92
+ expect(wire).to have_ruact_component("LikeButton").with_props(including("postId"))
93
+ end
94
+
95
+ it "fails with a legible message when props mismatch" do
96
+ wire = render_wire("<LikeButton postId={42} />")
97
+ expect do
98
+ expect(wire).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 99))
99
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError,
100
+ /props matching.*serialized wire form.*"postId".*42/m)
101
+ end
102
+
103
+ it "supports negation with props" do
104
+ wire = render_wire("<LikeButton postId={42} />")
105
+ expect(wire).not_to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 99))
106
+ end
107
+ end
108
+
109
+ describe "input shapes (D2)" do
110
+ it "accepts a response object exposing #body (raw wire)" do
111
+ wire = render_wire("<LikeButton postId={7} />")
112
+ response = response_double(wire, content_type: "text/x-component")
113
+ expect(response).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 7))
114
+ end
115
+
116
+ it "extracts Flight from an HTML shell embedding __FLIGHT_DATA" do
117
+ wire = render_wire("<LikeButton postId={5} />")
118
+ html = html_shell(wire)
119
+ expect(html).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 5))
120
+ end
121
+
122
+ it "ignores an unrelated earlier .push and anchors to __FLIGHT_DATA" do
123
+ wire = render_wire("<LikeButton postId={5} />")
124
+ shell = html_shell(wire)
125
+ # A realistic layout has other scripts BEFORE the ruact bootstrap.
126
+ noise = %(<script>window.dataLayer=window.dataLayer||[];dataLayer.push("not flight");</script>)
127
+ html = shell.sub("<div id=\"root\"></div>", "<div id=\"root\"></div>#{noise}")
128
+ expect(html).to have_ruact_component("LikeButton").with_props(a_hash_including("postId" => 5))
129
+ end
130
+
131
+ it "extracts Flight from an HTML-response object (content_type text/html)" do
132
+ wire = render_wire("<LikeButton postId={5} />")
133
+ response = response_double(html_shell(wire), content_type: "text/html; charset=utf-8")
134
+ expect(response).to have_ruact_component("LikeButton")
135
+ end
136
+
137
+ it "raises a clear error on a plain-JSON function-call body" do
138
+ json = response_double(%({"id":1,"title":"Hi"}), content_type: "application/json")
139
+ expect do
140
+ expect(json).to have_ruact_component("PostCard")
141
+ end.to raise_error(Ruact::Testing::NotAFlightResponseError, /plain-JSON response.*JSON\.parse/m)
142
+ end
143
+
144
+ it "raises a clear error on an empty (204) body" do
145
+ empty = response_double("", content_type: nil)
146
+ expect do
147
+ expect(empty).to have_ruact_component("PostCard")
148
+ end.to raise_error(Ruact::Testing::NotAFlightResponseError, /empty response body/m)
149
+ end
150
+
151
+ it "raises a clear error when handed an HTML doc with no Flight payload" do
152
+ html = "<!DOCTYPE html><html><body><h1>Nothing here</h1></body></html>"
153
+ expect do
154
+ expect(html).to have_ruact_component("PostCard")
155
+ end.to raise_error(Ruact::Testing::NotAFlightResponseError, /no `__FLIGHT_DATA` payload/m)
156
+ end
157
+ end
158
+
159
+ describe "escape round-trip through the HTML shell" do
160
+ it "recovers a payload containing quotes and interpolation-like sequences" do
161
+ # Props whose serialized form contains characters Ruby's #inspect
162
+ # escapes (`"`, `#{`, newlines) must survive extraction from the shell.
163
+ wire = render_wire("<LikeButton label={@label} />", label: %(a "quoted" \#{literal}\nvalue))
164
+ html = html_shell(wire)
165
+ expect(html).to have_ruact_component("LikeButton")
166
+ .with_props(a_hash_including("label" => a_string_matching(/quoted/)))
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "English"
5
+ require "shellwords"
6
+
7
+ # Story 15.4 (FR108), AC4c + AC2 — the public test surface must not load in a
8
+ # production boot, and the promoted parser/diff must be the SINGLE shipped
9
+ # implementation the internal `Ruact::Spec` matchers share (wrap, not fork).
10
+ module Ruact
11
+ RSpec.describe "Ruact::Testing load boundary", :story_15_4 do
12
+ it "a bare `require \"ruact\"` does NOT define the public test surface" do
13
+ # Shell out to a pristine Ruby process so nothing this suite already
14
+ # loaded (spec/support requires `ruact/testing/*`) pollutes the check.
15
+ lib = File.expand_path("../../../lib", __dir__)
16
+ script = <<~RUBY
17
+ $LOAD_PATH.unshift(#{lib.inspect})
18
+ require "ruact"
19
+ defined = Ruact.const_defined?(:Testing, false)
20
+ # `have_ruact_component` must not be registered on the RSpec matcher surface either.
21
+ rspec_loaded = $LOADED_FEATURES.any? { |f| f.include?("rspec/expectations") }
22
+ print [defined, rspec_loaded].inspect
23
+ RUBY
24
+
25
+ output = `#{RbConfig.ruby} -e #{Shellwords.escape(script)} 2>&1`
26
+ expect($CHILD_STATUS).to be_success, "subprocess failed: #{output}"
27
+ expect(output).to eq("[false, false]"),
28
+ "expected `require \"ruact\"` to leave Ruact::Testing undefined and " \
29
+ "RSpec unloaded, got: #{output}"
30
+ end
31
+
32
+ it "shares ONE implementation with the internal matchers (no fork)" do
33
+ require "ruact/testing"
34
+ require_relative "../../support/flight_wire_parser"
35
+ require_relative "../../support/matchers/flight_fixture_matcher"
36
+
37
+ expect(Ruact::Spec::FlightWireParser).to equal(Ruact::Testing::FlightWireParser)
38
+ expect(Ruact::Spec::FlightStructureDiff).to equal(Ruact::Testing::FlightStructureDiff)
39
+ end
40
+ end
41
+ end
@@ -1,135 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "strscan"
4
- require "json"
3
+ # Story 15.4 (FR108) — the pure Flight wire parser was PROMOTED onto the gem's
4
+ # `lib/` load path as `Ruact::Testing::FlightWireParser` so the new public
5
+ # `have_ruact_component` matcher and the gem's own internal matchers share ONE
6
+ # implementation (wrap, not fork). This file is now a thin shim: it loads the
7
+ # promoted implementation and re-exports it under the historical test-only
8
+ # `Ruact::Spec` namespace so the existing internal matcher wrappers and their
9
+ # self-tests keep working unchanged.
10
+ require "ruact/testing/flight_wire_parser"
5
11
 
6
12
  module Ruact
7
13
  # Test-support utilities. Code under `Ruact::Spec` is consumed only by the
8
14
  # gem's own RSpec suite; it is not part of the public API and may change
9
- # shape across stories without a deprecation cycle.
15
+ # shape across stories without a deprecation cycle. The parser/diff it names
16
+ # are aliases for the shipped `Ruact::Testing::*` implementation.
10
17
  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
18
+ FlightWireParser = Ruact::Testing::FlightWireParser
19
+ FlightWireParseError = Ruact::Testing::FlightWireParseError
134
20
  end
135
21
  end
@@ -1,267 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../flight_wire_parser"
4
+ require "ruact/testing/flight_structure_diff"
4
5
 
5
6
  module Ruact
6
7
  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
8
+ # Story 15.4 (FR108) the structural diff engine was PROMOTED onto the
9
+ # gem's `lib/` load path as `Ruact::Testing::FlightStructureDiff` (shared,
10
+ # single implementation wrap, not fork). This test-only `Ruact::Spec`
11
+ # alias keeps the internal matcher wrappers below and their self-tests
12
+ # working unchanged.
13
+ FlightStructureDiff = Ruact::Testing::FlightStructureDiff
265
14
  end
266
15
  end
267
16
 
@@ -151,11 +151,36 @@ export const __RUNTIME_VERSION__: number;
151
151
  *
152
152
  * The gem's own headers (`Accept`, `Content-Type`, `X-CSRF-Token`)
153
153
  * win over `defaultHeaders` — CSRF cannot be silently overridden.
154
+ *
155
+ * Story 15.6 (FR110) — `autoRevalidate` (default `false`) is the app-wide
156
+ * auto-revalidate default: when `true`, every successful, non-redirecting
157
+ * mutation `await`s an in-place Flight refresh of the current path (via
158
+ * `revalidate()`) before resolving with the action's JSON result. A per-call
159
+ * `withRefresh(accessor)` wins over this global default.
154
160
  */
155
161
  export function configureRuactRuntime(options: {
156
162
  defaultHeaders?: Record<string, string> | (() => Record<string, string>) | null;
163
+ autoRevalidate?: boolean;
157
164
  }): void;
158
165
 
166
+ /**
167
+ * Story 15.6 (FR110) — per-call auto-revalidate wrapper. Wraps a generated
168
+ * server-function accessor and returns a new accessor that forces an in-place
169
+ * Flight refresh of the current path after a successful, non-redirecting call —
170
+ * regardless of the app-wide `autoRevalidate` default (per-call explicit wins).
171
+ * Runtime-only: it does not change the codegen or the wire.
172
+ *
173
+ * A `$redirect` response still wins (the refresh is skipped); a failed mutation
174
+ * still throws before any refresh; and if no router is installed the descriptive
175
+ * `revalidate()` error surfaces (a successful mutation whose refresh rejects
176
+ * rejects the returned promise).
177
+ *
178
+ * The wrapped accessor keeps its own call signature (the same
179
+ * `(arg1?, arg2?) => Promise<unknown>` / `(formData) => Promise<void>`
180
+ * intersection the codegen emits).
181
+ */
182
+ export function withRefresh<F extends (...args: never[]) => Promise<unknown>>(accessor: F): F;
183
+
159
184
  /**
160
185
  * Re-run-4 (2026-05-15) — structured error thrown for 4xx/5xx responses.
161
186
  * Callers can branch on `status` and inspect `body` (already