ruact 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Specs for the three Flight wire matcher modes added in Story 7.5
|
|
6
|
+
# (`match_flight_fixture`, `match_flight_structure`, `include_flight_row`).
|
|
7
|
+
RSpec.describe "Flight wire matchers" do
|
|
8
|
+
# Captures an `ExpectationNotMetError` raised by the inner block so the
|
|
9
|
+
# spec can make multiple assertions on its message without resorting to a
|
|
10
|
+
# multi-line block chained off `raise_error`.
|
|
11
|
+
def capture_failure
|
|
12
|
+
yield
|
|
13
|
+
nil
|
|
14
|
+
rescue RSpec::Expectations::ExpectationNotMetError => e
|
|
15
|
+
e
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "match_flight_fixture (existing snapshot mode)" do
|
|
19
|
+
let(:nil_wire) { "0:null\n" }
|
|
20
|
+
|
|
21
|
+
it "passes against canonical fixture content (regression check)" do
|
|
22
|
+
expect(nil_wire).to match_flight_fixture("nil")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe "match_flight_structure" do
|
|
27
|
+
let(:simple_wire) { %(0:{"className":"box"}\n) }
|
|
28
|
+
let(:two_row_wire) { %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n) }
|
|
29
|
+
|
|
30
|
+
it "passes when the actual wire matches a single-row expected structure" do
|
|
31
|
+
expect(simple_wire).to match_flight_structure([
|
|
32
|
+
{ id: 0, class: :model, payload: { "className" => "box" } }
|
|
33
|
+
])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "passes for a two-row mixed import + model sequence" do
|
|
37
|
+
expect(two_row_wire).to match_flight_structure([
|
|
38
|
+
{ id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]] },
|
|
39
|
+
{ id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
|
|
40
|
+
])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "fails with a missing-row message when actual has fewer rows than expected" do
|
|
44
|
+
err = capture_failure do
|
|
45
|
+
expect(simple_wire).to match_flight_structure([
|
|
46
|
+
{ id: 0, class: :model, payload: { "className" => "box" } },
|
|
47
|
+
{ id: 1, class: :import, payload: [] }
|
|
48
|
+
])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
expect(err.message).to include("Expected row 1 (import) was not produced.")
|
|
52
|
+
expect(err.message).to include("expected: {")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "produces the AC3 verbatim row-indexed diff for a single-field semantic regression" do
|
|
56
|
+
broken_wire = %(0:["$X","div",null,{"className":"box","children":"hi"}]\n)
|
|
57
|
+
expected_payload = ["$", "div", nil, { "className" => "box", "children" => "hi" }]
|
|
58
|
+
got_payload = ["$X", "div", nil, { "className" => "box", "children" => "hi" }]
|
|
59
|
+
expected_structure = [{ id: 0, class: :model, payload: expected_payload }]
|
|
60
|
+
|
|
61
|
+
# Hash#inspect changed between Ruby 3.3 (`{"a"=>"b"}`) and Ruby 3.4
|
|
62
|
+
# (`{"a" => "b"}`). The AC3 contract is "values shown via .inspect" —
|
|
63
|
+
# so we render the expected message with the same .inspect the matcher
|
|
64
|
+
# uses at runtime, keeping the spec stable across the CI Ruby matrix.
|
|
65
|
+
expected_message = <<~MSG.strip
|
|
66
|
+
Expected Flight output to match structure.
|
|
67
|
+
|
|
68
|
+
Row 0 (model) differs at .payload[0]:
|
|
69
|
+
expected: "$"
|
|
70
|
+
got: "$X"
|
|
71
|
+
|
|
72
|
+
Row 0 (model) full diff:
|
|
73
|
+
expected: #{expected_payload.inspect}
|
|
74
|
+
got: #{got_payload.inspect}
|
|
75
|
+
MSG
|
|
76
|
+
|
|
77
|
+
expect do
|
|
78
|
+
expect(broken_wire).to match_flight_structure(expected_structure)
|
|
79
|
+
end.to raise_error(RSpec::Expectations::ExpectationNotMetError, expected_message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "fails with an unexpected-row message when actual has extra rows" do
|
|
83
|
+
err = capture_failure do
|
|
84
|
+
expect(two_row_wire).to match_flight_structure([
|
|
85
|
+
{ id: 1, class: :import,
|
|
86
|
+
payload: ["/L.jsx", "L", ["/L.jsx"]] }
|
|
87
|
+
])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
expect(err.message).to include("Got unexpected row 1 (model)")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "tolerates cosmetic JSON key reordering in payload hashes (AC4)" do
|
|
94
|
+
canonical = %(0:{"a":1,"b":2}\n)
|
|
95
|
+
perturbed = %(0:{"b":2,"a":1}\n)
|
|
96
|
+
expected_structure = [{ id: 0, class: :model, payload: { "a" => 1, "b" => 2 } }]
|
|
97
|
+
|
|
98
|
+
expect(canonical).to match_flight_structure(expected_structure)
|
|
99
|
+
expect(perturbed).to match_flight_structure(expected_structure)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "passes negation when the structure does not match" do
|
|
103
|
+
expect(simple_wire).not_to match_flight_structure([
|
|
104
|
+
{ id: 0, class: :model, payload: { "className" => "circle" } }
|
|
105
|
+
])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# AC1: "multiple I rows are an unordered set". The expected list below
|
|
109
|
+
# reverses the import order vs the wire — the structural matcher must
|
|
110
|
+
# still consider this a match because import-row ordering is not
|
|
111
|
+
# protocol-significant within the import class.
|
|
112
|
+
it "treats import rows as an unordered set (AC1)" do
|
|
113
|
+
wire = %(1:I["/A.jsx","A",["/A.jsx"]]\n2:I["/B.jsx","B",["/B.jsx"]]\n0:["$","$L1",null,{}]\n)
|
|
114
|
+
|
|
115
|
+
expect(wire).to match_flight_structure([
|
|
116
|
+
{ id: 2, class: :import, payload: ["/B.jsx", "B", ["/B.jsx"]] },
|
|
117
|
+
{ id: 1, class: :import, payload: ["/A.jsx", "A", ["/A.jsx"]] },
|
|
118
|
+
{ id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
|
|
119
|
+
])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Defends against incomplete expected rows silently passing when actual
|
|
123
|
+
# payload happens to be nil (e.g. `{ id: 0, class: :model }` without
|
|
124
|
+
# `:payload` would otherwise satisfy any row whose payload is nil).
|
|
125
|
+
it "raises ArgumentError when an expected row is missing :payload" do
|
|
126
|
+
expect do
|
|
127
|
+
expect(simple_wire).to match_flight_structure([{ id: 0, class: :model }])
|
|
128
|
+
end.to raise_error(ArgumentError, /missing required keys.*:payload/)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "raises ArgumentError when an expected row is missing :class" do
|
|
132
|
+
expect do
|
|
133
|
+
expect(simple_wire).to match_flight_structure([{ id: 0, payload: {} }])
|
|
134
|
+
end.to raise_error(ArgumentError, /missing required keys.*:class/)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Multi-row failure message: the count, AC3 wording for missing/extra,
|
|
138
|
+
# plus a "✓" line for every matching row so the reader can confirm
|
|
139
|
+
# which rows passed (AC3 — "Other rows that match are summarized as
|
|
140
|
+
# `Row N (<class>): ✓`").
|
|
141
|
+
it "shows matching-row checkmarks alongside multi-row diffs" do
|
|
142
|
+
wire = %(1:I["/A.jsx","A",["/A.jsx"]]\n0:["$X","div",null,{}]\n)
|
|
143
|
+
|
|
144
|
+
err = capture_failure do
|
|
145
|
+
expect(wire).to match_flight_structure([
|
|
146
|
+
{ id: 1, class: :import, payload: ["/A.jsx", "A", ["/A.jsx"]] },
|
|
147
|
+
{ id: 0, class: :model, payload: ["$", "div", nil, {}] },
|
|
148
|
+
{ id: 2, class: :model, payload: ["$", "span", nil, {}] }
|
|
149
|
+
])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
expect(err.message).to include("Expected Flight output to match structure. 2 rows differ:")
|
|
153
|
+
expect(err.message).to include("Row 0 (import): ✓")
|
|
154
|
+
expect(err.message).to include("Row 1 (model) differs at .payload[0]:")
|
|
155
|
+
expect(err.message).to include("Expected row 2 (model) was not produced.")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe "include_flight_row" do
|
|
160
|
+
let(:wire_with_post_id) do
|
|
161
|
+
%(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{"postId":42}]\n)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "matches when at least one row satisfies a hash_including payload predicate" do
|
|
165
|
+
expect(wire_with_post_id).to include_flight_row(
|
|
166
|
+
class: :model,
|
|
167
|
+
payload: include("postId" => 42)
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "fails listing parsed rows when no row matches the predicate" do
|
|
172
|
+
err = capture_failure do
|
|
173
|
+
expect(wire_with_post_id).to include_flight_row(
|
|
174
|
+
class: :model,
|
|
175
|
+
payload: include("postId" => 999)
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
expect(err.message).to include("Expected Flight output to include a row matching")
|
|
180
|
+
expect(err.message).to include("[0] id=1, class=import")
|
|
181
|
+
expect(err.message).to include("[1] id=0, class=model")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "supports negation with not_to" do
|
|
185
|
+
expect(wire_with_post_id).not_to include_flight_row(class: :error)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "fails negation when a row matches, naming the offending row index" do
|
|
189
|
+
expect do
|
|
190
|
+
expect(wire_with_post_id).not_to include_flight_row(class: :import)
|
|
191
|
+
end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /but row 0 matched/)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "supports array_including for the payload key" do
|
|
195
|
+
expect(wire_with_post_id).to include_flight_row(
|
|
196
|
+
class: :import,
|
|
197
|
+
payload: include("/L.jsx")
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# AC4 fixture-mode failure proof. The structural matcher tolerates the
|
|
202
|
+
# cosmetic perturbation; the fixture matcher fails *positively* with the
|
|
203
|
+
# expected-vs-got diff visible — confirming fixture mode is the wire-
|
|
204
|
+
# format contract guard. This spec verifies the failure message rather
|
|
205
|
+
# than relying on a `not_to` shortcut (which would prove only that the
|
|
206
|
+
# matcher returned false, not that the failure is loud and informative).
|
|
207
|
+
it "demonstrates cosmetic-vs-fixture asymmetry — structure tolerates re-ordering, fixture fails loudly (AC4)" do
|
|
208
|
+
canonical_wire = %(0:{"debug":true,"count":5,"label":"x"}\n)
|
|
209
|
+
perturbed_wire = %(0:{"label":"x","count":5,"debug":true}\n)
|
|
210
|
+
expected_structure = [
|
|
211
|
+
{ id: 0, class: :model, payload: { "debug" => true, "count" => 5, "label" => "x" } }
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
err = capture_failure do
|
|
215
|
+
expect(perturbed_wire).to match_flight_fixture("hash")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
aggregate_failures do
|
|
219
|
+
# Structural mode: both pass — JSON key reordering is cosmetic.
|
|
220
|
+
expect(canonical_wire).to match_flight_structure(expected_structure)
|
|
221
|
+
expect(perturbed_wire).to match_flight_structure(expected_structure)
|
|
222
|
+
|
|
223
|
+
# Fixture mode: canonical passes — the fixture file is the canonical
|
|
224
|
+
# wire bytes.
|
|
225
|
+
expect(canonical_wire).to match_flight_fixture("hash")
|
|
226
|
+
|
|
227
|
+
# Fixture mode against the perturbed wire fails *loudly* with the
|
|
228
|
+
# bytes-for-bytes diff so a human reviewer can see the cosmetic drift.
|
|
229
|
+
expect(err).to be_a(RSpec::Expectations::ExpectationNotMetError)
|
|
230
|
+
expect(err.message).to include("Expected output to match fixture at", "hash.txt", "Expected:", "Got:")
|
|
231
|
+
expect(err.message).to include(perturbed_wire.inspect)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Predicate validation: an unknown key (typo) must raise immediately.
|
|
236
|
+
# Otherwise `row[:payloed]` returns nil and `nil == nil` would silently
|
|
237
|
+
# match every row, hiding broken specs.
|
|
238
|
+
it "raises ArgumentError when the predicate has an unknown key" do
|
|
239
|
+
expect do
|
|
240
|
+
expect(wire_with_post_id).to include_flight_row(payloed: { "postId" => 42 })
|
|
241
|
+
end.to raise_error(ArgumentError, /unknown keys.*:payloed/)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "raises ArgumentError when given an empty predicate" do
|
|
245
|
+
expect do
|
|
246
|
+
expect(wire_with_post_id).to include_flight_row({})
|
|
247
|
+
end.to raise_error(ArgumentError, /predicate cannot be empty/)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/spec/support/rails_stub.rb
CHANGED
|
@@ -1,11 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
3
|
+
# Spec-only Rails bootstrap for tests that need a `Rails` constant. Two modes:
|
|
4
|
+
#
|
|
5
|
+
# 1. **Augment** (default since Story 7.9): when the `rails` gem is in the
|
|
6
|
+
# bundle, load its core (`rails.rb` — just `Rails::VERSION`,
|
|
7
|
+
# `Rails::Railtie`, `ActiveSupport::StringInquirer`, etc., *not*
|
|
8
|
+
# `action_controller` / `action_view`) and add test-only writers
|
|
9
|
+
# (`Rails.root=`, `Rails.env=`, `Rails.logger=`) on top. Specs that need
|
|
10
|
+
# the full Rails request cycle (e.g. controller_request_spec.rb) require
|
|
11
|
+
# `action_controller/railtie` and `action_view/railtie` themselves — the
|
|
12
|
+
# rest of the suite never pays that cost.
|
|
13
|
+
#
|
|
14
|
+
# 2. **Full stub** (fallback): when `rails` is not in the bundle (e.g. a
|
|
15
|
+
# matrix run that pruned it), provide a minimal `Rails` module + a
|
|
16
|
+
# `Rails::Railtie` class with no-op class methods so `gem/lib/ruact/railtie.rb`
|
|
17
|
+
# can `class Railtie < Rails::Railtie` without crashing. `$LOADED_FEATURES`
|
|
18
|
+
# is patched so `require "rails"` inside loaded files no-ops.
|
|
19
|
+
#
|
|
20
|
+
# Loaded automatically by spec_helper.
|
|
21
|
+
#
|
|
22
|
+
# Augmentation is idempotent (each `Rails.define_singleton_method` is guarded
|
|
23
|
+
# by `unless Rails.singleton_class.method_defined?(...)`), so we run through
|
|
24
|
+
# this file every time it loads — even when Rails was already required by
|
|
25
|
+
# another spec file (e.g. controller_request_spec.rb pre-loads
|
|
26
|
+
# `action_controller/railtie`). Skipping with `return if defined?(Rails)`
|
|
27
|
+
# would leave doctor_spec / railtie_spec without the test-only writers when
|
|
28
|
+
# the request spec runs first.
|
|
6
29
|
|
|
7
|
-
|
|
8
|
-
|
|
30
|
+
unless defined?(Rails)
|
|
31
|
+
begin
|
|
32
|
+
# Loads Rails core only — not the request-cycle subsystem. Cheap.
|
|
33
|
+
require "rails"
|
|
34
|
+
rescue LoadError
|
|
35
|
+
# Rails not available; fall through to the full stub below.
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if defined?(Rails) && Rails.respond_to?(:application)
|
|
40
|
+
# Augment-mode: real Rails is present. Add test-only writers that the
|
|
41
|
+
# existing suite (doctor_spec, railtie_spec) relies on, without clobbering
|
|
42
|
+
# real Rails's behaviour outside the test override.
|
|
43
|
+
unless Rails.singleton_class.method_defined?(:root=)
|
|
44
|
+
original_root = Rails.method(:root) if Rails.respond_to?(:root)
|
|
45
|
+
|
|
46
|
+
Rails.define_singleton_method(:root=) { |v| @_test_root = v }
|
|
47
|
+
Rails.define_singleton_method(:root) do
|
|
48
|
+
return @_test_root if defined?(@_test_root) && @_test_root
|
|
49
|
+
|
|
50
|
+
original_root&.call
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
unless Rails.singleton_class.method_defined?(:env=)
|
|
55
|
+
Rails.define_singleton_method(:env=) { |v| @_test_env = v }
|
|
56
|
+
original_env = Rails.method(:env) if Rails.respond_to?(:env)
|
|
57
|
+
Rails.define_singleton_method(:env) do
|
|
58
|
+
return @_test_env if defined?(@_test_env) && @_test_env
|
|
59
|
+
|
|
60
|
+
original_env ? original_env.call : ActiveSupport::StringInquirer.new("test")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless Rails.singleton_class.method_defined?(:logger=)
|
|
65
|
+
Rails.define_singleton_method(:logger=) { |v| @_test_logger = v }
|
|
66
|
+
original_logger = Rails.method(:logger) if Rails.respond_to?(:logger)
|
|
67
|
+
Rails.define_singleton_method(:logger) do
|
|
68
|
+
return @_test_logger if defined?(@_test_logger) && @_test_logger
|
|
69
|
+
|
|
70
|
+
original_logger&.call
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Full-stub fallback: rails gem is not in the bundle.
|
|
78
|
+
$LOADED_FEATURES << "rails.rb"
|
|
9
79
|
|
|
10
80
|
module Rails
|
|
11
81
|
class Railtie
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Story 8.1 — TypeScript declarations for the real server-functions runtime.
|
|
2
|
+
// Mirrors the JS exports in `index.js` so the generated module's
|
|
3
|
+
// `import { _makeRef } from "ruact/server-functions-runtime"` resolves under
|
|
4
|
+
// `tsc --noEmit` (AC10's import guarantee).
|
|
5
|
+
//
|
|
6
|
+
// The generated module's per-export signature is
|
|
7
|
+
// `(args?: Record<string, unknown>) => Promise<unknown>` per the 8.0a
|
|
8
|
+
// codegen contract; the runtime accepts a wider `FormData` argument too
|
|
9
|
+
// (Story 8.2 owns the codegen signature widening if it picks the FormData
|
|
10
|
+
// path). Devs writing call sites against the 8.0a-emitted module continue
|
|
11
|
+
// to see the conservative Record<string, unknown> signature; the FormData
|
|
12
|
+
// branch only fires through Story 8.2's `<form action={fn}>` wiring.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Re-run-3 (2026-05-15), refined Re-run-4 (2026-05-15) — local alias
|
|
16
|
+
* for the FormData INSTANCE type that does NOT require `lib: ["dom"]`
|
|
17
|
+
* in the consumer's tsconfig.
|
|
18
|
+
*
|
|
19
|
+
* Re-run-4 fix: pre-batch this inferred `F = typeof FormData` (the
|
|
20
|
+
* constructor), so DOM consumers passing `new FormData()` were typed
|
|
21
|
+
* against the constructor signature and `tsc` would reject the call.
|
|
22
|
+
* The conditional below now extracts the INSTANCE type from the
|
|
23
|
+
* constructor (`new (...args) => I`) when DOM lib is loaded, and
|
|
24
|
+
* falls back to a minimal structural shape in non-DOM targets.
|
|
25
|
+
*/
|
|
26
|
+
type RuactFormData = typeof globalThis extends { FormData: new (...args: never[]) => infer Instance }
|
|
27
|
+
? Instance
|
|
28
|
+
: { append(name: string, value: unknown): void };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Re-run-4 (2026-05-15) — same conditional-typeof pattern for the
|
|
32
|
+
* fetch `Response` type so the declaration compiles without DOM lib.
|
|
33
|
+
*/
|
|
34
|
+
type RuactResponse = typeof globalThis extends { Response: new (...args: never[]) => infer Instance }
|
|
35
|
+
? Instance
|
|
36
|
+
: unknown;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns a callable accessor for a server function registered with the
|
|
40
|
+
* given Ruby symbol name. The accessor, when invoked, POSTs the args to
|
|
41
|
+
* `/__ruact/fn/${name}`.
|
|
42
|
+
*
|
|
43
|
+
* Story 8.2 (refined 2026-05-17 per review patch R1) — the return type
|
|
44
|
+
* is an intersection of FOUR call signatures so the same exported
|
|
45
|
+
* reference is usable from every call site:
|
|
46
|
+
*
|
|
47
|
+
* 1. `()` / `(args)` / `(prevState, formData)` — direct callers and
|
|
48
|
+
* `useActionState`'s two-arg invocation; returns `Promise<unknown>`.
|
|
49
|
+
* 2. `(formData: FormData)` — assignable to React 19's `<form action>`
|
|
50
|
+
* prop, which is typed as `(formData: FormData) => void | Promise<void>`.
|
|
51
|
+
* Promise generics are invariant in TS, so `Promise<unknown>` is
|
|
52
|
+
* NOT assignable to `Promise<void>` even via the void-discard rule;
|
|
53
|
+
* the intersection lets `<form action={createPost}>` typecheck
|
|
54
|
+
* DIRECTLY against the emitted module without a call-site cast.
|
|
55
|
+
*
|
|
56
|
+
* Runtime behavior is unchanged — `_makeRef` always resolves with the
|
|
57
|
+
* JSON-decoded value. The `Promise<void>` overload is a TYPE-ONLY
|
|
58
|
+
* surface: when React invokes the function from a `<form action>` prop,
|
|
59
|
+
* the return value is discarded by React anyway.
|
|
60
|
+
*/
|
|
61
|
+
export function _makeRef(
|
|
62
|
+
name: string,
|
|
63
|
+
): ((
|
|
64
|
+
arg1?: Record<string, unknown> | RuactFormData,
|
|
65
|
+
arg2?: RuactFormData | Record<string, unknown>,
|
|
66
|
+
) => Promise<unknown>) &
|
|
67
|
+
((formData: RuactFormData) => Promise<void>);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Story 9.3 — the route-driven (v2) accessor. The codegen emits
|
|
71
|
+
* `_makeServerFunction({ method, path, segments })` for every non-GET routed
|
|
72
|
+
* action on a `Ruact::Server` controller. The returned callable targets the
|
|
73
|
+
* REAL Rails route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating
|
|
74
|
+
* dynamic path segments by name from the single call argument, and follows a
|
|
75
|
+
* Bucket-2 `{ "$redirect": "<path>" }` response client-side.
|
|
76
|
+
*
|
|
77
|
+
* Shares the same intersection call-signature contract as {@link _makeRef} so
|
|
78
|
+
* `<form action={createPost}>` and `useActionState` keep type-checking.
|
|
79
|
+
*/
|
|
80
|
+
export function _makeServerFunction(descriptor: {
|
|
81
|
+
method: string;
|
|
82
|
+
path: string;
|
|
83
|
+
segments?: string[];
|
|
84
|
+
}): ((
|
|
85
|
+
arg1?: Record<string, unknown> | RuactFormData,
|
|
86
|
+
arg2?: RuactFormData | Record<string, unknown>,
|
|
87
|
+
) => Promise<unknown>) &
|
|
88
|
+
((formData: RuactFormData) => Promise<void>);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Story 9.5 — the read-side (query) accessor. The codegen emits
|
|
92
|
+
* `_makeQuery({ path, kind: "query" })` for every method on a mounted
|
|
93
|
+
* `Ruact::Query` subclass. The returned callable issues a GET to the named
|
|
94
|
+
* query route (`GET /q/<jsId>`), serializing `params` into the query string
|
|
95
|
+
* (FR88: string / number / boolean / null only). Reads are CSRF-free: no
|
|
96
|
+
* body, no `X-CSRF-Token`.
|
|
97
|
+
*
|
|
98
|
+
* Usually consumed through {@link useQuery}, but callable directly in
|
|
99
|
+
* imperative code. The emitted module narrows the param surface per query
|
|
100
|
+
* (`() => Promise<unknown>` when the Ruby method declares no kwargs;
|
|
101
|
+
* `(params: Record<string, unknown>) => Promise<unknown>` when it does).
|
|
102
|
+
*/
|
|
103
|
+
export function _makeQuery(descriptor: {
|
|
104
|
+
path: string;
|
|
105
|
+
kind?: string;
|
|
106
|
+
}): (params?: Record<string, unknown>) => Promise<unknown>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Story 9.5 — React hook for reading a server query. Pass a query reference
|
|
110
|
+
* (the codegen-emitted `_makeQuery` accessor) and optional params; issues
|
|
111
|
+
* `GET /q/<jsId>` on mount (and whenever the serialized params change) and
|
|
112
|
+
* returns `{ data, loading, error }`. `loading` is true until the first
|
|
113
|
+
* resolution; `error` carries the structured {@link RuactActionError} on
|
|
114
|
+
* failure. A superseded in-flight response is dropped.
|
|
115
|
+
*
|
|
116
|
+
* Request de-duplication across components is Story 9.6; this hook fetches
|
|
117
|
+
* once per mount.
|
|
118
|
+
*/
|
|
119
|
+
export function useQuery<T = unknown>(
|
|
120
|
+
reference: (params?: Record<string, unknown>) => Promise<unknown>,
|
|
121
|
+
params?: Record<string, unknown>,
|
|
122
|
+
): { data: T | undefined; loading: boolean; error: unknown };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Story 8.2 — issues a Flight refetch of the supplied path (or the
|
|
126
|
+
* current URL when omitted) and swaps the React tree in place. Mirrors
|
|
127
|
+
* Next.js' `revalidatePath` ergonomic: call it after a server action
|
|
128
|
+
* settles when local React state is not enough to reflect the server
|
|
129
|
+
* mutation.
|
|
130
|
+
*
|
|
131
|
+
* Requires the ruact router to be installed (`setupRouter()` publishes
|
|
132
|
+
* `globalThis.__ruact_revalidate`). Throws a descriptive error when
|
|
133
|
+
* called without an installed router so the failure mode is loud rather
|
|
134
|
+
* than a silent no-op.
|
|
135
|
+
*/
|
|
136
|
+
export function revalidate(path?: string): Promise<void>;
|
|
137
|
+
|
|
138
|
+
/** Numeric sentinel downstream tooling can read to confirm the real
|
|
139
|
+
* runtime is in place (the Story 8.0a placeholder exported
|
|
140
|
+
* `__PLACEHOLDER__: true`; that export is removed in Story 8.1). */
|
|
141
|
+
export const __RUNTIME_VERSION__: number;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Re-run-5 (2026-05-15) — app-wide runtime configuration. Hosts in
|
|
145
|
+
* API mode (no CSRF meta tag) call this once at boot to register a
|
|
146
|
+
* default-headers function that supplies the `Authorization: Bearer …`
|
|
147
|
+
* (or similar) header on every server-function call.
|
|
148
|
+
*
|
|
149
|
+
* `defaultHeaders` accepts:
|
|
150
|
+
* - a plain object → merged on every call
|
|
151
|
+
* - a `() => object` function → called on every call (for tokens
|
|
152
|
+
* that may refresh at runtime)
|
|
153
|
+
* - `null` → clears any previously-registered default
|
|
154
|
+
*
|
|
155
|
+
* The gem's own headers (`Accept`, `Content-Type`, `X-CSRF-Token`)
|
|
156
|
+
* win over `defaultHeaders` — CSRF cannot be silently overridden.
|
|
157
|
+
*/
|
|
158
|
+
export function configureRuactRuntime(options: {
|
|
159
|
+
defaultHeaders?: Record<string, string> | (() => Record<string, string>) | null;
|
|
160
|
+
}): void;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Re-run-4 (2026-05-15) — structured error thrown for 4xx/5xx responses.
|
|
164
|
+
* Callers can branch on `status` and inspect `body` (already
|
|
165
|
+
* JSON-decoded if the server's Content-Type indicated JSON) instead
|
|
166
|
+
* of scraping the `message` string.
|
|
167
|
+
*/
|
|
168
|
+
export class RuactActionError extends Error {
|
|
169
|
+
readonly actionName: string;
|
|
170
|
+
readonly status: number;
|
|
171
|
+
readonly body: unknown;
|
|
172
|
+
readonly response: RuactResponse;
|
|
173
|
+
}
|