ruact 0.0.6 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  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 +18 -3
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +120 -1
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +149 -0
  11. data/lib/ruact/railtie.rb +14 -3
  12. data/lib/ruact/render_pipeline.rb +8 -1
  13. data/lib/ruact/serializable.rb +98 -6
  14. data/lib/ruact/server.rb +163 -0
  15. data/lib/ruact/server_functions/introspection.rb +81 -0
  16. data/lib/ruact/server_functions.rb +26 -4
  17. data/lib/ruact/testing/component_query.rb +113 -0
  18. data/lib/ruact/testing/flight_extractor.rb +221 -0
  19. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  20. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  21. data/lib/ruact/testing.rb +90 -0
  22. data/lib/ruact/version.rb +1 -1
  23. data/lib/ruact/view_helper.rb +11 -4
  24. data/lib/ruact.rb +1 -0
  25. data/lib/tasks/ruact.rake +55 -2
  26. data/spec/ruact/controller_spec.rb +7 -2
  27. data/spec/ruact/doctor_spec.rb +141 -0
  28. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  29. data/spec/ruact/install_generator_spec.rb +285 -0
  30. data/spec/ruact/manifest_resolver_spec.rb +174 -0
  31. data/spec/ruact/serializable_spec.rb +126 -0
  32. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  33. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  34. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  35. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  36. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  37. data/spec/spec_helper.rb +15 -0
  38. data/spec/support/flight_wire_parser.rb +12 -126
  39. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  40. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  41. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  42. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  43. data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
  44. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  45. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  46. metadata +17 -2
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Testing
5
+ # Pure structural diff + predicate-matching engine over parsed Flight rows
6
+ # (see {FlightWireParser}). No RSpec dependency — the RSpec matcher DSL that
7
+ # consumes it (the public `have_ruact_component` and the internal
8
+ # `match_flight_structure`/`include_flight_row`) lives elsewhere. Promoted
9
+ # from `spec/support` in Story 15.4 so the public helper and the internal
10
+ # matchers share ONE implementation (wrap, not fork).
11
+ # rubocop:disable Metrics/ClassLength -- single cohesive helper for matcher diffing/formatting; splitting would scatter related logic across files.
12
+ class FlightStructureDiff
13
+ # Keys the parser produces on every row. Predicates and expected rows
14
+ # may only reference these keys; unknown keys raise upfront so typos
15
+ # like `payloed:` don't silently match every row via `nil == nil`.
16
+ KNOWN_ROW_KEYS = %i[id class payload raw].freeze
17
+
18
+ # Keys that must be present in every expected-row hash passed to
19
+ # `match_flight_structure`. Excludes `:raw` because expected rows are
20
+ # authored as semantic descriptions, not byte-level snapshots.
21
+ REQUIRED_EXPECTED_KEYS = %i[id class payload].freeze
22
+
23
+ # Compute the set of differences between actual parsed rows and the
24
+ # expected structure. Import rows are matched as a multiset (their
25
+ # relative order among each other is not significant per AC1);
26
+ # everything else compares positionally.
27
+ def self.compute(actual_rows, expected_rows)
28
+ validate_expected_rows!(expected_rows)
29
+ return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length
30
+
31
+ diffs = []
32
+ pending_actual_imports = []
33
+ pending_expected_imports = []
34
+
35
+ actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
36
+ if actual[:class] == :import && expected[:class] == :import
37
+ pending_actual_imports << [i, actual]
38
+ pending_expected_imports << [i, expected]
39
+ next
40
+ end
41
+
42
+ next if rows_equal?(actual, expected)
43
+
44
+ diffs << build_field_diff(i, actual, expected)
45
+ end
46
+
47
+ diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
48
+ diffs.sort_by { |d| d[:idx] }
49
+ end
50
+
51
+ # Imports are an unordered multiset within their class (AC1). Sort both
52
+ # sides by `:id` (always unique per render) and any leftover diffs come
53
+ # from semantic mismatch in the same-position-after-sort pair.
54
+ def self.diff_imports_unordered(actual_pairs, expected_pairs)
55
+ return [] if actual_pairs.empty? && expected_pairs.empty?
56
+
57
+ sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
58
+ sorted_actual = actual_pairs.sort_by(&sort_key)
59
+ sorted_expected = expected_pairs.sort_by(&sort_key)
60
+
61
+ sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
62
+ next if rows_equal?(act_row, exp_row)
63
+
64
+ build_field_diff(act_idx, act_row, exp_row)
65
+ end
66
+ end
67
+
68
+ def self.diffs_for_length_mismatch(actual_rows, expected_rows)
69
+ diffs = []
70
+ [actual_rows.length, expected_rows.length].max.times do |i|
71
+ actual = actual_rows[i]
72
+ expected = expected_rows[i]
73
+ if actual.nil?
74
+ diffs << { kind: :missing, idx: i, expected_row: expected }
75
+ elsif expected.nil?
76
+ diffs << { kind: :extra, idx: i, actual_row: actual }
77
+ elsif !rows_equal?(actual, expected)
78
+ diffs << build_field_diff(i, actual, expected)
79
+ end
80
+ end
81
+ diffs
82
+ end
83
+
84
+ def self.rows_equal?(actual, expected)
85
+ REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
86
+ end
87
+
88
+ def self.build_field_diff(idx, actual, expected)
89
+ field_diff = nil
90
+ REQUIRED_EXPECTED_KEYS.each do |key|
91
+ next if actual[key] == expected[key]
92
+
93
+ path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
94
+ field_diff = { path: path, expected: sub_expected, got: sub_actual }
95
+ break
96
+ end
97
+
98
+ {
99
+ kind: :differs,
100
+ idx: idx,
101
+ row_class: actual[:class],
102
+ path: field_diff[:path],
103
+ expected: field_diff[:expected],
104
+ got: field_diff[:got],
105
+ expected_row: expected,
106
+ got_row: actual
107
+ }
108
+ end
109
+
110
+ # Walks parallel structures (Hash/Array/scalar) and returns the path,
111
+ # expected leaf, and actual leaf at the first differing position.
112
+ def self.first_difference(expected, actual, path)
113
+ return [path, expected, actual] if expected.class != actual.class
114
+ return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)
115
+
116
+ return diff_in_array(expected, actual, path) if expected.is_a?(Array)
117
+
118
+ diff_in_hash(expected, actual, path)
119
+ end
120
+
121
+ def self.diff_in_array(expected, actual, path)
122
+ [expected.length, actual.length].max.times do |i|
123
+ return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
124
+ next if expected[i] == actual[i]
125
+
126
+ return first_difference(expected[i], actual[i], "#{path}[#{i}]")
127
+ end
128
+ [path, expected, actual]
129
+ end
130
+
131
+ def self.diff_in_hash(expected, actual, path)
132
+ (expected.keys | actual.keys).each do |key|
133
+ return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
134
+ next if expected[key] == actual[key]
135
+
136
+ return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
137
+ end
138
+ [path, expected, actual]
139
+ end
140
+
141
+ # Validates each expected row carries the required `:id`, `:class`,
142
+ # `:payload` keys. Without this, an expected row of `{ id: 0, class:
143
+ # :model }` would silently pass against any actual row whose payload
144
+ # was nil — because the `==` check reads `expected[:payload]` as nil.
145
+ def self.validate_expected_rows!(expected_rows)
146
+ expected_rows.each_with_index do |row, i|
147
+ unless row.is_a?(Hash)
148
+ raise ArgumentError,
149
+ "match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
150
+ end
151
+
152
+ missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
153
+ next if missing.empty?
154
+
155
+ raise ArgumentError,
156
+ "match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
157
+ "Each expected row must include :id, :class, and :payload."
158
+ end
159
+ end
160
+
161
+ # Validates a predicate hash for `include_flight_row`. Unknown keys
162
+ # (typos like `payloed:` or `clas:`) raise immediately so they don't
163
+ # silently match any row via `row[:payloed]` returning nil.
164
+ def self.validate_predicate!(predicate)
165
+ unless predicate.is_a?(Hash)
166
+ raise ArgumentError,
167
+ "include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
168
+ end
169
+ raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?
170
+
171
+ unknown = predicate.keys - KNOWN_ROW_KEYS
172
+ return if unknown.empty?
173
+
174
+ raise ArgumentError,
175
+ "include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
176
+ "Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
177
+ end
178
+
179
+ def self.format_single(diff)
180
+ case diff[:kind]
181
+ when :missing
182
+ row = diff[:expected_row]
183
+ "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
184
+ when :extra
185
+ row = diff[:actual_row]
186
+ "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
187
+ else
188
+ format_field_diff(diff)
189
+ end
190
+ end
191
+
192
+ def self.format_field_diff(diff)
193
+ <<~MSG.strip
194
+ Expected Flight output to match structure.
195
+
196
+ Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
197
+ expected: #{diff[:expected].inspect}
198
+ got: #{diff[:got].inspect}
199
+
200
+ Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
201
+ expected: #{diff[:expected_row][:payload].inspect}
202
+ got: #{diff[:got_row][:payload].inspect}
203
+ MSG
204
+ end
205
+
206
+ # Builds the multi-row failure message: header naming the diff count,
207
+ # AC3-specified wording for missing / extra / differing rows, and a
208
+ # `Row N (<class>): ✓` summary line for every matching row so the
209
+ # reader can see what passed.
210
+ def self.format_multi(diffs, actual_rows, expected_rows)
211
+ header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
212
+ total = [actual_rows.length, expected_rows.length].max
213
+ diff_by_idx = diffs.to_h { |d| [d[:idx], d] }
214
+
215
+ body = (0...total).map do |i|
216
+ diff = diff_by_idx[i]
217
+ if diff
218
+ format_entry(diff)
219
+ else
220
+ row_class = (actual_rows[i] || expected_rows[i])[:class]
221
+ "Row #{i} (#{row_class}): ✓"
222
+ end
223
+ end
224
+
225
+ ([header, ""] + body).join("\n")
226
+ end
227
+
228
+ def self.format_entry(diff)
229
+ case diff[:kind]
230
+ when :missing
231
+ row = diff[:expected_row]
232
+ "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
233
+ when :extra
234
+ row = diff[:actual_row]
235
+ "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
236
+ else
237
+ <<~ENTRY.strip
238
+ Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
239
+ expected: #{diff[:expected].inspect}
240
+ got: #{diff[:got].inspect}
241
+ ENTRY
242
+ end
243
+ end
244
+
245
+ # Subset / case-equality match used by `include_flight_row`. Plain
246
+ # values use `==`; richer matchers (`hash_including`, `array_including`,
247
+ # `kind_of`, regexes) use `===` which delegates to their custom logic.
248
+ # Predicate keys are validated upfront by `validate_predicate!`, so
249
+ # this method can assume every key is one of the known row keys.
250
+ def self.row_matches?(row, predicate)
251
+ predicate.all? do |key, expected_value|
252
+ actual_value = row[key]
253
+ case expected_value
254
+ when Symbol, Numeric, NilClass, TrueClass, FalseClass
255
+ expected_value == actual_value
256
+ else
257
+ # rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
258
+ # (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
259
+ expected_value === actual_value
260
+ # rubocop:enable Style/CaseEquality
261
+ end
262
+ end
263
+ end
264
+ end
265
+ # rubocop:enable Metrics/ClassLength
266
+ end
267
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require "json"
5
+
6
+ module Ruact
7
+ # Public, host-app-facing test-support surface (FR108, Story 15.4). Loaded
8
+ # ONLY by an explicit `require "ruact/testing"` (or one of the files under
9
+ # `ruact/testing/`) — never by `require "ruact"`, so a production boot carries
10
+ # no RSpec/test dependency. The pure wire parser + structural diff engine live
11
+ # here as the SINGLE shipped implementation shared by both the public
12
+ # `have_ruact_component` matcher and the gem's internal `Ruact::Spec::*`
13
+ # matchers (promoted from `spec/support` in Story 15.4 — wrap, not fork).
14
+ module Testing
15
+ # Raised when {FlightWireParser.parse} encounters input it cannot decode.
16
+ # The message names the byte offset of the unparseable row so the caller
17
+ # can locate the problem in a printed wire string.
18
+ class FlightWireParseError < StandardError; end
19
+
20
+ # Parses a Flight wire byte string into an ordered array of row records.
21
+ #
22
+ # Used by the structural Flight matchers to assert on parsed semantics
23
+ # rather than literal bytes. Pure function — no I/O, no global state, no
24
+ # `Thread.current`.
25
+ #
26
+ # @example
27
+ # wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
28
+ # Ruact::Testing::FlightWireParser.parse(wire)
29
+ # # => [
30
+ # # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
31
+ # # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
32
+ # # ]
33
+ class FlightWireParser
34
+ # Parse a complete Flight wire byte string.
35
+ #
36
+ # @param wire [String] the raw bytes emitted by `Ruact::Flight::Renderer`.
37
+ # @return [Array<Hash>] one hash per row, in wire order. See class docs
38
+ # for the hash shape (`:id`, `:class`, `:payload`, `:raw`).
39
+ # @raise [Ruact::Testing::FlightWireParseError] when a row is malformed.
40
+ def self.parse(wire)
41
+ rows = []
42
+ scanner = StringScanner.new(wire)
43
+
44
+ until scanner.eos?
45
+ start_offset = scanner.pos
46
+ rows << parse_row(scanner, start_offset)
47
+ end
48
+
49
+ rows
50
+ end
51
+
52
+ def self.parse_row(scanner, start_offset)
53
+ # Hint rows have no ID: ":H<code><json>\n"
54
+ if scanner.peek(2) == ":H"
55
+ scanner.pos += 2
56
+ code = scanner.getch
57
+ raise_parse_error(start_offset, "missing hint code char") if code.nil?
58
+
59
+ json = read_to_newline(scanner, start_offset)
60
+ return {
61
+ id: nil,
62
+ class: :hint,
63
+ payload: [code, parse_json(json, start_offset)],
64
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
65
+ }
66
+ end
67
+
68
+ hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex id")
69
+ scanner.skip(":") || raise_parse_error(start_offset, "expected ':' after id")
70
+ id = hex.to_i(16)
71
+
72
+ case scanner.peek(1)
73
+ when "I" then parse_tagged(:import, scanner, id, start_offset)
74
+ when "T" then parse_text_row(scanner, id, start_offset)
75
+ when "E" then parse_tagged(:error, scanner, id, start_offset)
76
+ else parse_model_row(scanner, id, start_offset)
77
+ end
78
+ end
79
+
80
+ def self.parse_tagged(klass, scanner, id, start_offset)
81
+ scanner.getch # consume the tag byte (I or E)
82
+ json = read_to_newline(scanner, start_offset)
83
+ {
84
+ id: id,
85
+ class: klass,
86
+ payload: parse_json(json, start_offset),
87
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
88
+ }
89
+ end
90
+
91
+ def self.parse_model_row(scanner, id, start_offset)
92
+ json = read_to_newline(scanner, start_offset)
93
+ {
94
+ id: id,
95
+ class: :model,
96
+ payload: parse_json(json, start_offset),
97
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
98
+ }
99
+ end
100
+
101
+ def self.parse_text_row(scanner, id, start_offset)
102
+ scanner.getch # consume "T"
103
+ len_hex = scanner.scan(/\h+/) || raise_parse_error(start_offset, "expected hex length after T")
104
+ scanner.skip(",") || raise_parse_error(start_offset, "expected ',' after T<len>")
105
+ len = len_hex.to_i(16)
106
+
107
+ text = scanner.peek(len)
108
+ raise_parse_error(start_offset, "T row truncated") if text.nil? || text.bytesize < len
109
+
110
+ scanner.pos += len
111
+ {
112
+ id: id,
113
+ class: :text,
114
+ payload: text,
115
+ raw: scanner.string.byteslice(start_offset, scanner.pos - start_offset)
116
+ }
117
+ end
118
+
119
+ def self.read_to_newline(scanner, start_offset)
120
+ line = scanner.scan_until(/\n/) || raise_parse_error(start_offset, "missing trailing newline")
121
+ line.chomp
122
+ end
123
+
124
+ def self.parse_json(str, offset)
125
+ JSON.parse(str)
126
+ rescue JSON::ParserError => e
127
+ raise_parse_error(offset, "invalid JSON: #{e.message}")
128
+ end
129
+
130
+ def self.raise_parse_error(offset, reason)
131
+ raise FlightWireParseError, "FlightWireParser: cannot parse row at offset #{offset}: #{reason}"
132
+ end
133
+
134
+ private_class_method :parse_row, :parse_tagged, :parse_model_row, :parse_text_row,
135
+ :read_to_newline, :parse_json, :raise_parse_error
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ require_relative "testing/flight_wire_parser"
6
+ require_relative "testing/flight_structure_diff"
7
+ require_relative "testing/flight_extractor"
8
+ require_relative "testing/component_query"
9
+
10
+ # Public, host-app-facing render-assertion helpers (FR108, Story 15.4).
11
+ #
12
+ # Load this EXPLICITLY from your `spec_helper.rb`/`rails_helper.rb`:
13
+ #
14
+ # require "ruact/testing"
15
+ #
16
+ # It is NOT auto-loaded by `require "ruact"` — a production boot never pulls in
17
+ # RSpec. Requiring this file registers the `have_ruact_component` RSpec matcher
18
+ # so request/controller specs can assert a page rendered a given component:
19
+ #
20
+ # expect(response).to have_ruact_component("PostList")
21
+ # expect(response).to have_ruact_component("PostList").with_props(including("posts"))
22
+ # expect(response).not_to have_ruact_component("Admin")
23
+ #
24
+ # `response` may be an ActionDispatch/Rack response (its `.body` is read) or a
25
+ # raw String, in either page shape: a raw `text/x-component` body or an HTML
26
+ # shell embedding `__FLIGHT_DATA`. A `Ruact::Server` function-call/query answer
27
+ # is plain JSON, not Flight — passing it raises a clear error pointing you at
28
+ # `JSON.parse(response.body)` (see the "Testing" docs page).
29
+ #
30
+ # This is a STABLE public API (a conventional test matcher) — it wraps, and
31
+ # does not fork, the internal Story-7.5 structural parser/diff promoted to
32
+ # `Ruact::Testing::FlightWireParser` / `Ruact::Testing::FlightStructureDiff`.
33
+ module Ruact
34
+ module Testing
35
+ # Compares a rendered instance's serialized props against the value passed
36
+ # to `.with_props`. A plain Hash requires exact equality (via `===`, i.e.
37
+ # `==`); an RSpec argument matcher (`hash_including`, `including`, `a_hash_…`)
38
+ # drives subset/fuzzy semantics through its own `#===`.
39
+ def self.props_match?(expected, actual_props)
40
+ # rubocop:disable Style/CaseEquality -- intentional: lets RSpec matchers (hash_including, …) drive semantics via #===.
41
+ expected === actual_props
42
+ # rubocop:enable Style/CaseEquality
43
+ end
44
+ end
45
+ end
46
+
47
+ # rubocop:disable Metrics/BlockLength -- a single cohesive RSpec matcher definition (match + chain + messages).
48
+ RSpec::Matchers.define :have_ruact_component do |name|
49
+ match do |actual|
50
+ @query = Ruact::Testing::ComponentQuery.new(Ruact::Testing::FlightExtractor.extract(actual))
51
+ @instances_props = @query.props_for(name)
52
+ next false if @instances_props.empty?
53
+ next true unless defined?(@expected_props)
54
+
55
+ @instances_props.any? { |props| Ruact::Testing.props_match?(@expected_props, props) }
56
+ end
57
+
58
+ chain :with_props do |expected|
59
+ @expected_props = expected
60
+ end
61
+
62
+ failure_message do |_actual|
63
+ if @instances_props && @instances_props.empty?
64
+ rendered = @query.rendered_names
65
+ found = rendered.empty? ? "no ruact components" : "components: #{rendered.inspect}"
66
+ "expected the response to have rendered a ruact component named #{name.inspect}, " \
67
+ "but found #{found}."
68
+ else
69
+ "expected a rendered #{name.inspect} to have props matching #{@expected_props.inspect}, " \
70
+ "but none did. Rendered instance props (serialized wire form):\n" \
71
+ "#{@instances_props.map(&:inspect).join("\n")}"
72
+ end
73
+ end
74
+
75
+ failure_message_when_negated do |_actual|
76
+ if defined?(@expected_props)
77
+ "expected the response NOT to have a #{name.inspect} with props matching " \
78
+ "#{@expected_props.inspect}, but one did."
79
+ else
80
+ "expected the response NOT to have rendered a ruact component named #{name.inspect}, " \
81
+ "but it did."
82
+ end
83
+ end
84
+
85
+ description do
86
+ base = "have ruact component #{name.inspect}"
87
+ defined?(@expected_props) ? "#{base} with props #{@expected_props.inspect}" : base
88
+ end
89
+ end
90
+ # rubocop:enable Metrics/BlockLength
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.8"
5
5
  end
@@ -83,9 +83,10 @@ module Ruact
83
83
  # @vitejs/plugin-react normally injects this preamble by processing index.html.
84
84
  # Since our HTML is generated by Rails (not Vite), we inject it manually.
85
85
  # Without it, every JSX file throws "can't detect preamble" at runtime.
86
+ dev_server = Ruact.config.vite_dev_server.chomp("/")
86
87
  react_preamble = <<~JS
87
88
  <script type="module">
88
- import RefreshRuntime from 'http://localhost:5173/@react-refresh';
89
+ import RefreshRuntime from '#{dev_server}/@react-refresh';
89
90
  RefreshRuntime.injectIntoGlobalHook(window);
90
91
  window.$RefreshReg$ = () => {};
91
92
  window.$RefreshSig$ = () => (type) => type;
@@ -94,7 +95,7 @@ module Ruact
94
95
  JS
95
96
 
96
97
  react_preamble + <<~HTML
97
- <script type="module" src="http://localhost:5173/@vite/client"></script>
98
+ <script type="module" src="#{dev_server}/@vite/client"></script>
98
99
  <script type="module" src="#{ruact_bootstrap_dev_url}"></script>
99
100
  HTML
100
101
  else
@@ -112,12 +113,18 @@ module Ruact
112
113
  # `/@id/__x00__virtual:ruact/bootstrap`. A plain `/virtual:...` URL falls
113
114
  # through to the dev server's HTML fallback, so this encoding is required.
114
115
  def ruact_bootstrap_dev_url
115
- "http://localhost:5173/@id/__x00__#{Ruact.bootstrap_virtual_id}"
116
+ "#{Ruact.config.vite_dev_server.chomp('/')}/@id/__x00__#{Ruact.bootstrap_virtual_id}"
116
117
  end
117
118
 
119
+ # Probe the CONFIGURED dev server (host:port parsed from
120
+ # `Ruact.config.vite_dev_server`), not a hardcoded localhost:5173 — so a
121
+ # custom `vite_dev_server` and the emitted dev `<script>` URLs always agree
122
+ # on whether the server is up.
118
123
  def vite_dev_running?
119
124
  require "socket"
120
- Socket.tcp("localhost", 5173, connect_timeout: 1).close
125
+ require "uri"
126
+ uri = URI.parse(Ruact.config.vite_dev_server)
127
+ Socket.tcp(uri.host || "localhost", uri.port || 5173, connect_timeout: 1).close
121
128
  true
122
129
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
123
130
  false
data/lib/ruact.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "ruact/erb_preprocessor"
12
12
  require_relative "ruact/render_context"
13
13
  require_relative "ruact/html_converter"
14
14
  require_relative "ruact/client_manifest"
15
+ require_relative "ruact/manifest_resolver"
15
16
  require_relative "ruact/render_pipeline"
16
17
  require_relative "ruact/view_helper"
17
18
  require_relative "ruact/erb_preprocessor_hook"
data/lib/tasks/ruact.rake CHANGED
@@ -1,10 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :ruact do
4
- desc "Check ruact installation and configuration (FR27)"
4
+ # Story 15.3 (FR107) `--json` is passed after a `--` separator so neither
5
+ # Rails nor Rake tries to parse it as an option: `bin/rails ruact:doctor -- --json`.
6
+ # Everything after `--` lands in ARGV, which the task scans. A captured local
7
+ # (not a constant) so re-loading this rakefile — e.g. specs that load it into
8
+ # an isolated Rake::Application — does not warn about constant redefinition.
9
+ json_mode = -> { ARGV.include?("--json") }
10
+
11
+ desc "Check ruact installation and configuration (FR27). Append `-- --json` " \
12
+ "for a machine-readable report (EXPERIMENTAL shape — gate on schema_version)."
5
13
  task doctor: :environment do
6
14
  require "ruact/doctor"
7
- exit 1 unless Ruact::Doctor.run
15
+
16
+ if json_mode.call
17
+ require "json"
18
+ report = Ruact::Doctor.new.as_json
19
+ puts JSON.pretty_generate(report)
20
+ exit 1 unless report["status"] == "pass"
21
+ else
22
+ exit 1 unless Ruact::Doctor.run
23
+ end
24
+ end
25
+
26
+ # Story 15.3 (FR107) — read-only introspection of the accessor/route table
27
+ # (the SAME single source of truth codegen consumes) for headless agents & CI.
28
+ # Side-effect-free: it does NOT write the codegen bridge or the TS module.
29
+ # `-- --json` emits the machine-readable document; the bare task prints a
30
+ # compact human table. Exits 1 (message on stderr) on a naming collision,
31
+ # mirroring `ruact:server_functions:generate`.
32
+ desc "Print the ruact accessor/route table. Append `-- --json` for a " \
33
+ "machine-readable document (EXPERIMENTAL shape — gate on schema_version)."
34
+ task routes: :environment do
35
+ require "ruact/server_functions"
36
+
37
+ begin
38
+ Rails.application.routes_reloader.execute_unless_loaded
39
+
40
+ if json_mode.call
41
+ require "json"
42
+ puts JSON.pretty_generate(
43
+ Ruact::ServerFunctions::Introspection.as_json(route_set: Rails.application.routes)
44
+ )
45
+ else
46
+ entries = Ruact::ServerFunctions.introspect(route_set: Rails.application.routes)
47
+ if entries.empty?
48
+ puts "[ruact] no server-function accessors (no Ruact::Server actions or mounted queries)"
49
+ else
50
+ puts "[ruact] server-function accessors:"
51
+ entries.each do |entry|
52
+ puts format(" %-6<verb>s %-28<accessor>s %<path>s",
53
+ verb: entry["http_method"], accessor: entry["js_identifier"], path: entry["path"])
54
+ end
55
+ end
56
+ end
57
+ rescue Ruact::ConfigurationError, Errno::ENOENT, JSON::ParserError => e
58
+ warn "[ruact] error: #{e.message}"
59
+ exit 1
60
+ end
8
61
  end
9
62
 
10
63
  namespace :server_functions do
@@ -39,10 +39,15 @@ module Ruact
39
39
  let(:controller) { test_class.new(fake_request) }
40
40
 
41
41
  describe "#ruact_manifest" do
42
- it "reads from Ruact.manifest (AC#6)" do
42
+ # Opt OUT of spec_helper's suite-wide resolver stub here so this proves the
43
+ # actual delegation: `#ruact_manifest` resolves through ManifestResolver
44
+ # (prod returns the boot-loaded Ruact.manifest; dev fetches over HTTP).
45
+ it "delegates manifest resolution to ManifestResolver.resolve" do
43
46
  test_manifest = ClientManifest.from_hash({})
44
- allow(Ruact).to receive(:manifest).and_return(test_manifest)
47
+ allow(ManifestResolver).to receive(:resolve).and_return(test_manifest)
48
+
45
49
  expect(controller.send(:ruact_manifest)).to be test_manifest
50
+ expect(ManifestResolver).to have_received(:resolve)
46
51
  end
47
52
  end
48
53