ruact 0.0.1 → 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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. 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