ruact 0.0.7 → 0.0.9
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 +59 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +378 -6
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +14 -46
- data/lib/ruact/doctor.rb +102 -12
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- 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 +10 -1
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +222 -6
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +727 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -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/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/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +30 -4
|
@@ -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
|
@@ -44,12 +44,21 @@ module Ruact
|
|
|
44
44
|
# (no drift). Available in every view via the railtie's
|
|
45
45
|
# `ActionView::Base.include(Ruact::ViewHelper)`.
|
|
46
46
|
#
|
|
47
|
+
# Called from a LAYOUT the argument is normally omitted: the payload for the
|
|
48
|
+
# render in flight is picked up from `@ruact_flight_payload`, which
|
|
49
|
+
# `Ruact::Controller#render_ruact_document` sets on the controller and Rails
|
|
50
|
+
# copies to the view (the name does not match the `/\A@_/` protected-ivar
|
|
51
|
+
# filter). On a plain Rails page — no ruact render in flight — that ivar is
|
|
52
|
+
# absent and the helper emits only the entry tags, exactly as before.
|
|
53
|
+
#
|
|
47
54
|
# @param flight_payload [String, nil] the per-render Flight wire payload to
|
|
48
|
-
# inline as `__FLIGHT_DATA
|
|
55
|
+
# inline as `__FLIGHT_DATA`. Omit inside a layout to use the render in
|
|
56
|
+
# flight; an explicit argument always wins.
|
|
49
57
|
# @return [ActiveSupport::SafeBuffer] the asset markup, html_safe
|
|
50
58
|
# @example In a layout
|
|
51
59
|
# <%= ruact_js_assets %>
|
|
52
60
|
def ruact_js_assets(flight_payload = nil)
|
|
61
|
+
flight_payload ||= @ruact_flight_payload
|
|
53
62
|
parts = []
|
|
54
63
|
parts << ruact_flight_data_script(flight_payload) unless flight_payload.nil?
|
|
55
64
|
parts << ruact_vite_tags
|
data/lib/ruact.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "ruact/serializable"
|
|
|
7
7
|
require_relative "ruact/signed_references"
|
|
8
8
|
require_relative "ruact/flight"
|
|
9
9
|
require_relative "ruact/string_distance"
|
|
10
|
+
require_relative "ruact/layout_source"
|
|
10
11
|
require_relative "ruact/component_contract"
|
|
11
12
|
require_relative "ruact/erb_preprocessor"
|
|
12
13
|
require_relative "ruact/render_context"
|
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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# An UNMIGRATED host layout: it has the React root but never calls
|
|
2
|
+
`ruact_js_assets`, so rendering the page through it would produce a
|
|
3
|
+
document with no bootstrap entry and no Flight payload — a blank page.
|
|
4
|
+
ruact must detect that and fall back to its built-in shell instead. %>
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<link rel="stylesheet" href="/host-app.css" />
|
|
9
|
+
<title>Host App Title</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<%= yield %>
|
|
13
|
+
<%# ruact: root %>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# An UNMIGRATED layout that cannot survive being rendered on a ruact page.
|
|
2
|
+
It reads an ivar that only a plain Rails action would have set — exactly the
|
|
3
|
+
shape of a real pre-migration app, whose layout has never once run on a
|
|
4
|
+
ruact-rendered page. If ruact renders this speculatively just to discover it
|
|
5
|
+
lacks `ruact_js_assets`, the app 500s on a page that used to work.
|
|
6
|
+
|
|
7
|
+
NOTE: this comment MENTIONS the helper name on purpose — a readiness check
|
|
8
|
+
that greps for the bare string counts this layout as wired and renders it. %>
|
|
9
|
+
<%# <%= ruact_js_assets %> %>
|
|
10
|
+
<%# ...and the line above is a genuinely COMMENTED-OUT call, which is the
|
|
11
|
+
sharper version of the same trap: ERB comments do not nest, so it emits
|
|
12
|
+
nothing, but a check that only looks for `<%=` sees a call. Both shapes must
|
|
13
|
+
read as UNMIGRATED, or this layout gets rendered and 500s below. %>
|
|
14
|
+
<!DOCTYPE html>
|
|
15
|
+
<html>
|
|
16
|
+
<head>
|
|
17
|
+
<title><%= @page_title.upcase %></title>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<%= yield %>
|
|
21
|
+
<%# ruact: root %>
|
|
22
|
+
<div id="root"></div>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|