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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +18 -3
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +120 -1
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +11 -4
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +174 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- 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
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -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 '
|
|
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="
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|