ruact 0.0.2 → 0.0.3
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 +86 -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 +1680 -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 +89 -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 +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -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 +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -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 +75 -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 +446 -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 +429 -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 +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -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 +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -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 +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "action_controller"
|
|
7
|
+
|
|
8
|
+
# Spec_helper's rails_stub defines Rails; the railtie was not auto-required
|
|
9
|
+
# because Rails was not yet defined when ruact.rb evaluated `require_relative
|
|
10
|
+
# "ruact/railtie" if defined?(Rails)`. Load it explicitly (mirrors
|
|
11
|
+
# spec/ruact/railtie_spec.rb).
|
|
12
|
+
require "ruact/railtie"
|
|
13
|
+
require "ruact/controller"
|
|
14
|
+
require "ruact/server"
|
|
15
|
+
|
|
16
|
+
# Story 8.0a — Railtie.write_server_functions_snapshot! is the entry point
|
|
17
|
+
# wired into `config.to_prepare`. The full to_prepare boot lives in
|
|
18
|
+
# controller_request_spec.rb; here we exercise the class method directly with
|
|
19
|
+
# Rails.root pointed at a tmpdir, which is enough to validate the contract
|
|
20
|
+
# (Story 8.0a Task 2.6 — Railtie path resolution + write-if-changed).
|
|
21
|
+
module Ruact
|
|
22
|
+
module ServerFunctions
|
|
23
|
+
RSpec.describe "Ruact::Railtie.write_server_functions_snapshot!", :story_8_0a do
|
|
24
|
+
around do |example|
|
|
25
|
+
Dir.mktmpdir do |dir|
|
|
26
|
+
original_root = Rails.root
|
|
27
|
+
Rails.root = Pathname.new(dir)
|
|
28
|
+
@tmpdir = dir
|
|
29
|
+
example.run
|
|
30
|
+
ensure
|
|
31
|
+
Rails.root = original_root
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
let(:path) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
|
|
36
|
+
|
|
37
|
+
it "writes the JSON to tmp/cache/ruact/server-functions.json (Story 8.0a)" do
|
|
38
|
+
result = Ruact::Railtie.write_server_functions_snapshot!
|
|
39
|
+
expect(result).to be(true)
|
|
40
|
+
expect(File).to exist(path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "writes an empty `functions: []` array when both registries are empty " \
|
|
44
|
+
"(Story 8.0a — empty-registry contract)" do
|
|
45
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
46
|
+
parsed = JSON.parse(File.read(path))
|
|
47
|
+
expect(parsed.fetch("functions")).to eq([])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "the file is short-circuited on a second call with an unchanged registry " \
|
|
51
|
+
"(Story 8.0a — pitfall #1)" do
|
|
52
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
53
|
+
expect(Ruact::Railtie.write_server_functions_snapshot!).to be(false)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "rewrites the file after a registration is added (Story 8.0a)" do
|
|
57
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
58
|
+
Ruact.action_registry.register(:demo_ping, kind: :action)
|
|
59
|
+
|
|
60
|
+
expect(Ruact::Railtie.write_server_functions_snapshot!).to be(true)
|
|
61
|
+
parsed = JSON.parse(File.read(path))
|
|
62
|
+
expect(parsed["functions"].map { |fn| fn["ruby_symbol"] }).to eq(["demo_ping"])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
RSpec.describe "Ruact::ServerFunctions.write_v2_snapshot! (Story 9.3)", :story_9_3 do
|
|
67
|
+
# A real Ruact::Server host so RouteSource's default constant-resolving
|
|
68
|
+
# host predicate recognizes it; stub_const (not a literal class) keeps the
|
|
69
|
+
# file single-definition and the constant scoped to the example.
|
|
70
|
+
before do
|
|
71
|
+
stub_const("V2DemoPostsController", Class.new(ActionController::Base) { include Ruact::Server })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
around do |example|
|
|
75
|
+
Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def route_set
|
|
79
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
80
|
+
rs.draw { resources :v2_demo_posts, only: %i[create update destroy] }
|
|
81
|
+
rs
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write!(logger: nil)
|
|
85
|
+
Ruact::ServerFunctions.write_v2_snapshot!(
|
|
86
|
+
route_set: route_set, root: Pathname.new(@tmpdir), logger: logger
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
let(:next_json) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.next.json") }
|
|
91
|
+
let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
|
|
92
|
+
let(:real_json) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
|
|
93
|
+
|
|
94
|
+
it "writes the v2 bridge + TS to the PARALLEL .next target (not the real file)" do
|
|
95
|
+
entries = write!
|
|
96
|
+
|
|
97
|
+
expect(entries.map { |e| e["js_identifier"] })
|
|
98
|
+
.to match_array(%w[createV2DemoPost updateV2DemoPost destroyV2DemoPost])
|
|
99
|
+
expect(File).to exist(next_json)
|
|
100
|
+
expect(File).to exist(next_ts)
|
|
101
|
+
# AC5/AC6 — the v1 real bridge is NOT written by the v2 path.
|
|
102
|
+
expect(File).not_to exist(real_json)
|
|
103
|
+
expect(JSON.parse(File.read(next_json)).fetch("version")).to eq(2)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "renders _makeServerFunction calls targeting real routes into the .next TS" do
|
|
107
|
+
write!
|
|
108
|
+
ts = File.read(next_ts)
|
|
109
|
+
expect(ts).to include('import { _makeServerFunction } from "ruact/server-functions-runtime";')
|
|
110
|
+
expect(ts).to include('_makeServerFunction({ method: "POST", path: "/v2_demo_posts", segments: [] });')
|
|
111
|
+
expect(ts).to include('_makeServerFunction({ method: "PATCH", path: "/v2_demo_posts/:id", segments: ["id"] });')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "is byte-stable across calls on an unchanged route table (no churn)" do
|
|
115
|
+
write!
|
|
116
|
+
first = File.read(next_ts)
|
|
117
|
+
write!
|
|
118
|
+
expect(File.read(next_ts)).to eq(first)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "logs the exposed function names (AC2 — transparency over silence)" do
|
|
122
|
+
logger = instance_double(Logger, info: nil)
|
|
123
|
+
write!(logger: logger)
|
|
124
|
+
expect(logger).to have_received(:info).with(/\[ruact\] codegen: exposing .*createV2DemoPost/)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
RSpec.describe "Ruact::Railtie registry-clear hook (Story 8.1)", :story_8_1 do
|
|
129
|
+
# The Railtie attaches a `before_class_unload` callback that clears both
|
|
130
|
+
# registries before Zeitwerk tears down constants — this prevents removed
|
|
131
|
+
# `ruact_action` declarations from lingering across reloads. The full
|
|
132
|
+
# Rails-app boot covering the controller class-body re-evaluation lives
|
|
133
|
+
# in `controller_request_spec.rb`; here we exercise the hook directly.
|
|
134
|
+
before do
|
|
135
|
+
Ruact.action_registry.clear!
|
|
136
|
+
Ruact.query_registry.clear!
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "clears both registries when invoked" do
|
|
140
|
+
Ruact.action_registry.register(:foo, kind: :action)
|
|
141
|
+
Ruact.query_registry.register(:bar, kind: :query)
|
|
142
|
+
expect(Ruact.action_registry.size).to eq(1)
|
|
143
|
+
expect(Ruact.query_registry.size).to eq(1)
|
|
144
|
+
|
|
145
|
+
# Direct invocation of the cleanup that the reloader hook would run.
|
|
146
|
+
Ruact.action_registry.clear!
|
|
147
|
+
Ruact.query_registry.clear!
|
|
148
|
+
|
|
149
|
+
expect(Ruact.action_registry.size).to eq(0)
|
|
150
|
+
expect(Ruact.query_registry.size).to eq(0)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "the snapshot write-if-changed guard skips a rewrite when controllers " \
|
|
154
|
+
"re-register the same symbols after a clear (Story 8.1 — pitfall #1 mitigation)" do
|
|
155
|
+
Dir.mktmpdir do |dir|
|
|
156
|
+
original_root = Rails.root
|
|
157
|
+
Rails.root = Pathname.new(dir)
|
|
158
|
+
|
|
159
|
+
Ruact.action_registry.register(:create_post, kind: :action)
|
|
160
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
161
|
+
original_bytes = File.read(File.join(dir, "tmp/cache/ruact/server-functions.json"))
|
|
162
|
+
|
|
163
|
+
# Simulate a reload cycle: clear, then re-register the same symbol
|
|
164
|
+
# with a fresh class object (the same as what would happen when
|
|
165
|
+
# controller class bodies re-evaluate after Zeitwerk teardown).
|
|
166
|
+
Ruact.action_registry.clear!
|
|
167
|
+
Ruact.action_registry.register(:create_post, kind: :action)
|
|
168
|
+
|
|
169
|
+
expect(Ruact::Railtie.write_server_functions_snapshot!).to be(false)
|
|
170
|
+
expect(File.read(File.join(dir, "tmp/cache/ruact/server-functions.json"))).to eq(original_bytes)
|
|
171
|
+
ensure
|
|
172
|
+
Rails.root = original_root
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Story 8.1 Re-run-6/8 — force_load_controllers! now walks Rails::Engine
|
|
178
|
+
# subclasses so engine-owned `ruact_action` declarations populate the
|
|
179
|
+
# registry at boot (not on first request to the engine controller).
|
|
180
|
+
# The regression target: a mounted engine that declares its own controller
|
|
181
|
+
# with `ruact_action :engine_action` must be visible to the snapshot
|
|
182
|
+
# writer + endpoint dispatcher BEFORE any HTTP traffic.
|
|
183
|
+
RSpec.describe "Ruact::Railtie.force_load_controllers! engine scanning (Story 8.1)", :story_8_1 do
|
|
184
|
+
before do
|
|
185
|
+
Ruact.action_registry.clear!
|
|
186
|
+
Ruact.query_registry.clear!
|
|
187
|
+
|
|
188
|
+
# In a real Rails boot, `require_dependency` is added to `Object` by
|
|
189
|
+
# `ActiveSupport::Dependencies.hook!` before `config.to_prepare`
|
|
190
|
+
# fires. The minimal spec-env setup (rails_stub + action_controller
|
|
191
|
+
# core) does not invoke the hook, so we stub the call directly to
|
|
192
|
+
# delegate to plain `load(file)` — which is sufficient to exercise
|
|
193
|
+
# the engine-scanning branch without dragging the full dependencies
|
|
194
|
+
# subsystem into the suite.
|
|
195
|
+
allow(Ruact::Railtie).to receive(:force_load_dir).and_wrap_original do |_original, dir|
|
|
196
|
+
files = Dir.glob("#{dir}/**/*_controller.rb")
|
|
197
|
+
files.each { |file| load(file) }
|
|
198
|
+
files.length
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "loads ruact_action declarations from a mounted Rails::Engine's app/controllers " \
|
|
203
|
+
"(re-run-6 #4 / re-run-8 #2 — engine-owned controllers must populate the registry at boot)" do
|
|
204
|
+
Dir.mktmpdir do |engine_dir|
|
|
205
|
+
# Build the engine's controller file on disk. The file's body
|
|
206
|
+
# declares a real `ruact_action` so populating the registry is
|
|
207
|
+
# observable (no mocks of the macro itself).
|
|
208
|
+
controllers_dir = File.join(engine_dir, "app/controllers")
|
|
209
|
+
FileUtils.mkdir_p(controllers_dir)
|
|
210
|
+
controller_path = File.join(controllers_dir, "engine_demo_controller.rb")
|
|
211
|
+
File.write(controller_path, <<~RUBY)
|
|
212
|
+
# frozen_string_literal: true
|
|
213
|
+
|
|
214
|
+
class EngineDemoController < ActionController::Base
|
|
215
|
+
include Ruact::Controller
|
|
216
|
+
|
|
217
|
+
ruact_action(:engine_only_action) { |_params| "from-engine" }
|
|
218
|
+
end
|
|
219
|
+
RUBY
|
|
220
|
+
|
|
221
|
+
# Build a real Rails::Engine subclass whose paths["app/controllers"]
|
|
222
|
+
# points at the on-disk controllers directory. `Engine#paths` is
|
|
223
|
+
# automatically populated by Rails.
|
|
224
|
+
fake_engine = Class.new(Rails::Engine) do
|
|
225
|
+
isolate_namespace Module.new
|
|
226
|
+
config.paths["app/controllers"] = controllers_dir
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Stub Rails::Engine.subclasses to return JUST our fake engine — the
|
|
230
|
+
# host app's own engine class is filtered out inside
|
|
231
|
+
# force_load_controllers! by an explicit `engine_class ==
|
|
232
|
+
# Rails.application.class` skip.
|
|
233
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([fake_engine])
|
|
234
|
+
|
|
235
|
+
expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
|
|
236
|
+
expect(Ruact.action_registry.entries[:engine_only_action]).not_to be_nil
|
|
237
|
+
expect(Ruact.action_registry.entries[:engine_only_action].controller).to be(EngineDemoController)
|
|
238
|
+
end
|
|
239
|
+
ensure
|
|
240
|
+
# `EngineDemoController` is loaded via `require_dependency` against an
|
|
241
|
+
# absolute on-disk path; remove the constant so re-runs of the spec
|
|
242
|
+
# don't trip the macro's "method already defined" guard.
|
|
243
|
+
Object.send(:remove_const, :EngineDemoController) if defined?(EngineDemoController)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it "skips the host application's own Rails::Engine subclass " \
|
|
247
|
+
"(avoids double-loading app/controllers already covered by the Rails.application branch)" do
|
|
248
|
+
# Rails.application.class IS a Rails::Engine subclass; force_load_controllers!
|
|
249
|
+
# iterates the host app FIRST via the application branch, then skips it
|
|
250
|
+
# explicitly in the engine branch. Confirm that filtering happens.
|
|
251
|
+
host_class = Rails.application.class
|
|
252
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([host_class])
|
|
253
|
+
|
|
254
|
+
# We expect ZERO additional load operations from the engine branch
|
|
255
|
+
# because the only subclass is the host app itself.
|
|
256
|
+
expect(Ruact::Railtie).not_to receive(:safe_engine_instance)
|
|
257
|
+
|
|
258
|
+
expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "swallows a misconfigured engine (engine_class.instance raising) " \
|
|
262
|
+
"via safe_engine_instance so a single broken engine cannot block boot" do
|
|
263
|
+
bad_engine = Class.new(Rails::Engine)
|
|
264
|
+
allow(bad_engine).to receive(:instance).and_raise(StandardError, "engine boot failed")
|
|
265
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([bad_engine])
|
|
266
|
+
|
|
267
|
+
# Must not propagate; force_load_controllers! returns normally.
|
|
268
|
+
expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Story 8.3 — force_load_server_function_hosts! ALSO walks
|
|
273
|
+
# `app/server_actions/**/*.rb` so standalone modules register at boot
|
|
274
|
+
# alongside controller-hosted actions. Follows the Story 8.1 fake-engine
|
|
275
|
+
# pattern to bypass Rails.application's sticky root memoization.
|
|
276
|
+
RSpec.describe "Ruact::Railtie.force_load_server_function_hosts! " \
|
|
277
|
+
"app/server_actions/ scanning (Story 8.3)", :story_8_3 do
|
|
278
|
+
before do
|
|
279
|
+
Ruact.action_registry.clear!
|
|
280
|
+
Ruact.query_registry.clear!
|
|
281
|
+
|
|
282
|
+
# Same stub as the Story 8.1 engine-scanning describe — substitutes
|
|
283
|
+
# `require_dependency` (unavailable in the minimal spec env) with
|
|
284
|
+
# plain `load`. Accepts both `dir` (positional) and `glob:` (kwarg).
|
|
285
|
+
allow(Ruact::Railtie).to receive(:force_load_dir).and_wrap_original do |_original, dir, glob: "**/*_controller.rb"|
|
|
286
|
+
files = Dir.glob("#{dir}/#{glob}")
|
|
287
|
+
files.each { |file| load(file) }
|
|
288
|
+
files.length
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it "loads ruact_action declarations from app/server_actions/ at boot " \
|
|
293
|
+
"(Pitfall #7 — standalone modules must register before the snapshot writer runs)" do
|
|
294
|
+
Dir.mktmpdir do |engine_dir|
|
|
295
|
+
server_actions_dir = File.join(engine_dir, "app/server_actions")
|
|
296
|
+
FileUtils.mkdir_p(server_actions_dir)
|
|
297
|
+
module_path = File.join(server_actions_dir, "standalone_railtie_demo.rb")
|
|
298
|
+
File.write(module_path, <<~RUBY)
|
|
299
|
+
# frozen_string_literal: true
|
|
300
|
+
|
|
301
|
+
module StandaloneRailtieDemo
|
|
302
|
+
extend Ruact::ServerAction
|
|
303
|
+
|
|
304
|
+
ruact_action(:standalone_railtie_demo) { |_p| "from-standalone" }
|
|
305
|
+
end
|
|
306
|
+
RUBY
|
|
307
|
+
|
|
308
|
+
fake_engine = Class.new(Rails::Engine) do
|
|
309
|
+
isolate_namespace Module.new
|
|
310
|
+
# Register the path explicitly so `server_actions_paths_for`
|
|
311
|
+
# finds it via the Rails paths enumerator.
|
|
312
|
+
config.paths.add "app/server_actions", with: server_actions_dir
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Stub Rails::Engine.subclasses to expose ONLY the fake engine —
|
|
316
|
+
# the host app's own controllers/server_actions are filtered out
|
|
317
|
+
# by the engine_class == Rails.application.class skip inside
|
|
318
|
+
# force_load_server_function_hosts!.
|
|
319
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([fake_engine])
|
|
320
|
+
|
|
321
|
+
expect { Ruact::Railtie.force_load_server_function_hosts! }.not_to raise_error
|
|
322
|
+
|
|
323
|
+
entry = Ruact.action_registry.entries[:standalone_railtie_demo]
|
|
324
|
+
expect(entry).not_to be_nil
|
|
325
|
+
expect(entry.controller).to be(StandaloneRailtieDemo)
|
|
326
|
+
expect(entry.controller).to be_a(Module)
|
|
327
|
+
expect(entry.controller).not_to be_a(Class)
|
|
328
|
+
end
|
|
329
|
+
ensure
|
|
330
|
+
Object.send(:remove_const, :StandaloneRailtieDemo) if defined?(StandaloneRailtieDemo)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it "silently no-ops when no engine has an app/server_actions/ directory " \
|
|
334
|
+
"(typical for apps that only use controller-hosted actions)" do
|
|
335
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([])
|
|
336
|
+
expect { Ruact::Railtie.force_load_server_function_hosts! }.not_to raise_error
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it "back-compat: the old `force_load_controllers!` name aliases to the new method" do
|
|
340
|
+
expect(Ruact::Railtie.method(:force_load_controllers!).original_name)
|
|
341
|
+
.to eq(:force_load_server_function_hosts!)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "rake"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
# Story 8.0a — exercises `rake ruact:server_functions:generate` end-to-end:
|
|
9
|
+
# the task's body runs against a tmpdir as Rails.root, stubs the
|
|
10
|
+
# :environment dependency, and asserts that:
|
|
11
|
+
# 1. The JSON bridge is written to tmp/cache/ruact/.
|
|
12
|
+
# 2. The TypeScript module is written to app/javascript/.ruact/.
|
|
13
|
+
# 3. Re-running the task is idempotent (no rewrites on unchanged registry).
|
|
14
|
+
# 4. ConfigurationError surfaces with a non-zero exit and a `[ruact] error:` line.
|
|
15
|
+
module Ruact
|
|
16
|
+
module ServerFunctions
|
|
17
|
+
RSpec.describe "rake ruact:server_functions:generate", :story_8_0a do
|
|
18
|
+
around do |example|
|
|
19
|
+
Dir.mktmpdir do |dir|
|
|
20
|
+
original_root = Rails.root
|
|
21
|
+
Rails.root = Pathname.new(dir)
|
|
22
|
+
@tmpdir = dir
|
|
23
|
+
|
|
24
|
+
prev = Rake.application
|
|
25
|
+
Rake.application = Rake::Application.new
|
|
26
|
+
Rake.application.define_task(Rake::Task, :environment)
|
|
27
|
+
load File.expand_path("../../../lib/tasks/ruact.rake", __dir__)
|
|
28
|
+
|
|
29
|
+
example.run
|
|
30
|
+
ensure
|
|
31
|
+
Rake.application = prev
|
|
32
|
+
Rails.root = original_root
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:json_path) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
|
|
37
|
+
let(:ts_path) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.ts") }
|
|
38
|
+
|
|
39
|
+
def invoke!
|
|
40
|
+
Rake::Task["ruact:server_functions:generate"].reenable
|
|
41
|
+
Rake::Task["ruact:server_functions:generate"].invoke
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "registers the task under the documented name (Story 8.0a)" do
|
|
45
|
+
expect(Rake.application.lookup("ruact:server_functions:generate")).not_to be_nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "writes both the JSON bridge and the TS module on first run (Story 8.0a)",
|
|
49
|
+
:aggregate_failures do
|
|
50
|
+
invoke!
|
|
51
|
+
expect(File).to exist(json_path)
|
|
52
|
+
expect(File).to exist(ts_path)
|
|
53
|
+
expect(File.read(ts_path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "is idempotent: a second run does not change file contents (Story 8.0a)" do
|
|
57
|
+
invoke!
|
|
58
|
+
before = File.read(ts_path)
|
|
59
|
+
invoke!
|
|
60
|
+
expect(File.read(ts_path)).to eq(before)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "produces the byte-identical TS module to Codegen.render (Story 8.0a AC7)" do
|
|
64
|
+
Ruact.action_registry.register(:demo_ping, kind: :action)
|
|
65
|
+
invoke!
|
|
66
|
+
|
|
67
|
+
snapshot = Snapshot.dump(Ruact.action_registry, Ruact.query_registry,
|
|
68
|
+
now: Time.parse(JSON.parse(File.read(json_path))["generated_at"]))
|
|
69
|
+
expect(File.read(ts_path)).to eq(Codegen.render(snapshot))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "exits 1 with a `[ruact] error:` line when the registry has an invalid symbol " \
|
|
73
|
+
"(Story 8.0a — rake error reporting)" do
|
|
74
|
+
# Inject a bad entry by bypassing the registry's validation (the
|
|
75
|
+
# naming-bridge raises on .register; we want to verify what happens
|
|
76
|
+
# when an invalid entry slipped through and the task re-validates).
|
|
77
|
+
allow(Snapshot).to receive(:generate!)
|
|
78
|
+
.and_raise(Ruact::ConfigurationError,
|
|
79
|
+
"ruact_action / ruact_query symbol :RECALCULATE must match /^[a-z_][a-z0-9_]*$/")
|
|
80
|
+
expect { invoke! }
|
|
81
|
+
.to raise_error(SystemExit)
|
|
82
|
+
.and output(/\[ruact\] error:.*RECALCULATE/).to_stderr
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module ServerFunctions
|
|
7
|
+
RSpec.describe Registry, :story_8_0a do
|
|
8
|
+
subject(:registry) { described_class.new }
|
|
9
|
+
|
|
10
|
+
let(:posts_controller) do
|
|
11
|
+
Class.new { def self.name = "PostsController" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:bar_controller) do
|
|
15
|
+
Class.new { def self.name = "BarController" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "#register (Story 8.0a)" do
|
|
19
|
+
it "stores a single entry keyed by its Ruby symbol", :aggregate_failures do
|
|
20
|
+
entry = registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
21
|
+
|
|
22
|
+
expect(entry).to be_a(RegistryEntry)
|
|
23
|
+
expect(entry.ruby_symbol).to eq(:create_post)
|
|
24
|
+
expect(entry.js_identifier).to eq("createPost")
|
|
25
|
+
expect(entry.kind).to eq(:action)
|
|
26
|
+
expect(entry.controller).to eq(posts_controller)
|
|
27
|
+
expect(registry.entries.keys).to eq([:create_post])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "captures the implementation block verbatim for downstream invocation" do
|
|
31
|
+
block = -> { :pong }
|
|
32
|
+
entry = registry.register(:demo_ping, kind: :action, controller: posts_controller, &block)
|
|
33
|
+
expect(entry.block).to be(block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "allows registering an action and a query with the same symbol in separate registries" do
|
|
37
|
+
actions = described_class.new
|
|
38
|
+
queries = described_class.new
|
|
39
|
+
actions.register(:create_post, kind: :action, controller: posts_controller)
|
|
40
|
+
queries.register(:create_post, kind: :query, controller: posts_controller)
|
|
41
|
+
expect(actions.entries.keys).to eq([:create_post])
|
|
42
|
+
expect(queries.entries.keys).to eq([:create_post])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "raises Ruact::ConfigurationError for SCREAMING_SNAKE symbols (Story 8.0a)" do
|
|
46
|
+
expect { registry.register(:RECALCULATE, kind: :action, controller: posts_controller) }
|
|
47
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
48
|
+
expect(error.message).to include(":RECALCULATE")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "raises Ruact::ConfigurationError on JS-identifier collision and names both " \
|
|
53
|
+
"Ruby symbols and both controllers (Story 8.0a)", :aggregate_failures do
|
|
54
|
+
registry.register(:foo_bar, kind: :action, controller: posts_controller)
|
|
55
|
+
expect { registry.register(:foo__bar, kind: :action, controller: bar_controller) }
|
|
56
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
57
|
+
expect(error.message).to include(":foo_bar")
|
|
58
|
+
expect(error.message).to include(":foo__bar")
|
|
59
|
+
expect(error.message).to include("PostsController")
|
|
60
|
+
expect(error.message).to include("BarController")
|
|
61
|
+
expect(error.message).to include('"fooBar"')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "allows re-registering the same Ruby symbol (replace semantics, dev reload)" do
|
|
66
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
67
|
+
expect do
|
|
68
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
69
|
+
end.not_to raise_error
|
|
70
|
+
expect(registry.size).to eq(1)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "tolerates a nil controller (Rails-console registration path)" do
|
|
74
|
+
expect { registry.register(:create_post, kind: :action) }.not_to raise_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects kinds other than :action / :query (Chunk1 Major 2026-05-13)" do
|
|
78
|
+
expect { registry.register(:create_post, kind: :wat, controller: posts_controller) }
|
|
79
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
80
|
+
expect(error.message).to include(":create_post")
|
|
81
|
+
expect(error.message).to include("PostsController")
|
|
82
|
+
expect(error.message).to include(":wat")
|
|
83
|
+
expect(error.message).to include("[:action, :query]")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "wraps NameBridge symbol-shape failures with AC7 'invalid server-function " \
|
|
88
|
+
"symbol :SYMBOL in CONTROLLER' framing (Re-run patch m5)" do
|
|
89
|
+
expect { registry.register(:RECALCULATE, kind: :action, controller: posts_controller) }
|
|
90
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
91
|
+
expect(error.message).to start_with("invalid server-function symbol :RECALCULATE in PostsController")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe "#entries (Story 8.0a)" do
|
|
97
|
+
it "returns a frozen snapshot independent of subsequent mutations" do
|
|
98
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
99
|
+
snapshot = registry.entries
|
|
100
|
+
expect(snapshot).to be_frozen
|
|
101
|
+
registry.register(:list_posts, kind: :query, controller: posts_controller)
|
|
102
|
+
expect(snapshot.keys).to eq([:create_post])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "#clear! (Story 8.0a)" do
|
|
107
|
+
it "wipes all entries and returns self" do
|
|
108
|
+
registry.register(:create_post, kind: :action, controller: posts_controller)
|
|
109
|
+
expect(registry.clear!).to be(registry)
|
|
110
|
+
expect(registry).to be_empty
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe "Story 8.3 — mixed controller+standalone collision", :story_8_3 do
|
|
115
|
+
let(:posts_controller_class) do
|
|
116
|
+
Class.new { def self.name = "PostsController" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
let(:standalone_create_post_module) do
|
|
120
|
+
Module.new do
|
|
121
|
+
extend Ruact::ServerAction
|
|
122
|
+
|
|
123
|
+
def self.name
|
|
124
|
+
"CreatePost"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "raises Ruact::ConfigurationError when the same Ruby symbol is declared in a controller " \
|
|
130
|
+
"AND in a standalone module — message names BOTH hosts" do
|
|
131
|
+
registry.register(:create_post, kind: :action, controller: posts_controller_class)
|
|
132
|
+
|
|
133
|
+
expect do
|
|
134
|
+
registry.register(:create_post, kind: :action, controller: standalone_create_post_module)
|
|
135
|
+
end.to raise_error(Ruact::ConfigurationError) do |error|
|
|
136
|
+
expect(error.message).to include(":create_post")
|
|
137
|
+
expect(error.message).to include("PostsController")
|
|
138
|
+
expect(error.message).to include("CreatePost")
|
|
139
|
+
expect(error.message).to include("declared in BOTH")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "raises Ruact::ConfigurationError when the same symbol is declared in standalone " \
|
|
144
|
+
"first, then in a controller (order-independent)" do
|
|
145
|
+
registry.register(:create_post, kind: :action, controller: standalone_create_post_module)
|
|
146
|
+
|
|
147
|
+
expect do
|
|
148
|
+
registry.register(:create_post, kind: :action, controller: posts_controller_class)
|
|
149
|
+
end.to raise_error(Ruact::ConfigurationError) do |error|
|
|
150
|
+
expect(error.message).to include("PostsController")
|
|
151
|
+
expect(error.message).to include("CreatePost")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "describe_controller names a Module host correctly (no inspection fallback) " \
|
|
156
|
+
"when one side of the collision is a Module" do
|
|
157
|
+
registry.register(:create_post, kind: :action, controller: standalone_create_post_module)
|
|
158
|
+
another_module = Module.new do
|
|
159
|
+
extend Ruact::ServerAction
|
|
160
|
+
|
|
161
|
+
def self.name
|
|
162
|
+
"AdminCreatePost"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Cross-bridge JS-identifier collision: two DIFFERENT Ruby symbols
|
|
167
|
+
# producing the SAME JS identifier — bridges into `js_identifier ==`
|
|
168
|
+
# branch of detect_collision!. The bridge collapses underscores,
|
|
169
|
+
# so `:create_post` and `:create__post` both → "createPost".
|
|
170
|
+
expect do
|
|
171
|
+
registry.register(:create__post, kind: :action, controller: another_module)
|
|
172
|
+
end.to raise_error(Ruact::ConfigurationError) do |error|
|
|
173
|
+
expect(error.message).to include("CreatePost")
|
|
174
|
+
expect(error.message).to include("AdminCreatePost")
|
|
175
|
+
expect(error.message).to include('"createPost"')
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe "Ruact module-level accessors (Story 8.0a)" do
|
|
181
|
+
it "returns two independent Registry singletons" do
|
|
182
|
+
expect(Ruact.action_registry).to be_a(described_class)
|
|
183
|
+
expect(Ruact.query_registry).to be_a(described_class)
|
|
184
|
+
expect(Ruact.action_registry).not_to equal(Ruact.query_registry)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it "memoizes the same instance across calls" do
|
|
188
|
+
expect(Ruact.action_registry).to equal(Ruact.action_registry)
|
|
189
|
+
expect(Ruact.query_registry).to equal(Ruact.query_registry)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "both registries are empty at boot (Story 8.1 / 9.1 populate them)" do
|
|
193
|
+
expect(Ruact.action_registry).to be_empty
|
|
194
|
+
expect(Ruact.query_registry).to be_empty
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|