ruact 0.0.4 → 0.0.6

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -12,15 +12,21 @@ require "action_controller"
12
12
  require "ruact/railtie"
13
13
  require "ruact/controller"
14
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).
15
+ require "ruact/routing"
16
+ require "ruact/query"
17
+
18
+ # Story 9.9 the route-driven (v2) codegen is the sole writer of the real
19
+ # bridge. `Railtie.write_server_functions_snapshot!` is the entry point wired
20
+ # into `config.to_prepare`; it forces the route table to load, then delegates to
21
+ # `ServerFunctions.write_v2_snapshot!`, which writes the v2 snapshot to the REAL
22
+ # bridge (`tmp/cache/ruact/server-functions.json`).
21
23
  module Ruact
22
24
  module ServerFunctions
23
- RSpec.describe "Ruact::Railtie.write_server_functions_snapshot!", :story_8_0a do
25
+ RSpec.describe "Ruact::Railtie.write_server_functions_snapshot! (Story 9.9)", :story_9_9 do
26
+ before do
27
+ stub_const("WrapperDemoPostsController", Class.new(ActionController::Base) { include Ruact::Server })
28
+ end
29
+
24
30
  around do |example|
25
31
  Dir.mktmpdir do |dir|
26
32
  original_root = Rails.root
@@ -34,32 +40,51 @@ module Ruact
34
40
 
35
41
  let(:path) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
36
42
 
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)
43
+ # Drives the wrapper by stubbing Rails.application with a routes set + a
44
+ # no-op routes_reloader (the cold-boot ordering fold-in).
45
+ def stub_application(routes)
46
+ reloader = Object.new
47
+ def reloader.execute_unless_loaded; end
48
+ app = Object.new
49
+ app.define_singleton_method(:routes) { routes }
50
+ app.define_singleton_method(:routes_reloader) { reloader }
51
+ allow(Rails).to receive(:application).and_return(app)
41
52
  end
42
53
 
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([])
54
+ def route_set
55
+ rs = ActionDispatch::Routing::RouteSet.new
56
+ rs.draw { resources :wrapper_demo_posts, only: %i[create update destroy] }
57
+ rs
48
58
  end
49
59
 
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)
60
+ it "writes the v2 JSON to the REAL bridge (tmp/cache/ruact/server-functions.json)" do
61
+ stub_application(route_set)
62
+ entries = Ruact::Railtie.write_server_functions_snapshot!
63
+ expect(entries.map { |e| e["js_identifier"] })
64
+ .to match_array(%w[createWrapperDemoPost updateWrapperDemoPost destroyWrapperDemoPost])
65
+ expect(File).to exist(path)
66
+ expect(JSON.parse(File.read(path)).fetch("version")).to eq(2)
54
67
  end
55
68
 
56
- it "rewrites the file after a registration is added (Story 8.0a)" do
69
+ it "writes an empty `functions: []` array when no routes are exposed" do
70
+ empty = ActionDispatch::Routing::RouteSet.new
71
+ empty.draw { get "/health", to: "health#show" }
72
+ stub_application(empty)
57
73
  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"])
74
+ expect(JSON.parse(File.read(path)).fetch("functions")).to eq([])
75
+ end
76
+
77
+ it "forces the route table to load before reading it (cold-boot ordering fold-in)" do
78
+ reloader = Object.new
79
+ called = false
80
+ reloader.define_singleton_method(:execute_unless_loaded) { called = true }
81
+ routes = route_set
82
+ app = Object.new
83
+ app.define_singleton_method(:routes) { routes }
84
+ app.define_singleton_method(:routes_reloader) { reloader }
85
+ allow(Rails).to receive(:application).and_return(app)
86
+ Ruact::Railtie.write_server_functions_snapshot!
87
+ expect(called).to be(true)
63
88
  end
64
89
  end
65
90
 
@@ -87,25 +112,24 @@ module Ruact
87
112
  )
88
113
  end
89
114
 
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") }
115
+ # Story 9.9 — the v2 codegen writes the REAL bridge (the `.next` parallel
116
+ # target was demolished).
92
117
  let(:real_json) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
118
+ let(:real_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.ts") }
93
119
 
94
- it "writes the v2 bridge + TS to the PARALLEL .next target (not the real file)" do
120
+ it "writes the v2 bridge + TS to the REAL target" do
95
121
  entries = write!
96
122
 
97
123
  expect(entries.map { |e| e["js_identifier"] })
98
124
  .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)
125
+ expect(File).to exist(real_json)
126
+ expect(File).to exist(real_ts)
127
+ expect(JSON.parse(File.read(real_json)).fetch("version")).to eq(2)
104
128
  end
105
129
 
106
- it "renders _makeServerFunction calls targeting real routes into the .next TS" do
130
+ it "renders _makeServerFunction calls targeting real routes into the TS" do
107
131
  write!
108
- ts = File.read(next_ts)
132
+ ts = File.read(real_ts)
109
133
  expect(ts).to include('import { _makeServerFunction } from "ruact/server-functions-runtime";')
110
134
  expect(ts).to include('_makeServerFunction({ method: "POST", path: "/v2_demo_posts", segments: [] });')
111
135
  expect(ts).to include('_makeServerFunction({ method: "PATCH", path: "/v2_demo_posts/:id", segments: ["id"] });')
@@ -113,9 +137,9 @@ module Ruact
113
137
 
114
138
  it "is byte-stable across calls on an unchanged route table (no churn)" do
115
139
  write!
116
- first = File.read(next_ts)
140
+ first = File.read(real_ts)
117
141
  write!
118
- expect(File.read(next_ts)).to eq(first)
142
+ expect(File.read(real_ts)).to eq(first)
119
143
  end
120
144
 
121
145
  it "logs the exposed function names (AC2 — transparency over silence)" do
@@ -126,11 +150,6 @@ module Ruact
126
150
  end
127
151
 
128
152
  RSpec.describe "Ruact::ServerFunctions.write_v2_snapshot! — queries (Story 9.5)", :story_9_5 do
129
- # `ruact_queries` + query dispatch live behind these requires (loaded
130
- # explicitly here as the railtie would at boot).
131
- require "ruact/routing"
132
- require "ruact/query"
133
-
134
153
  around do |example|
135
154
  Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
136
155
  end
@@ -154,7 +173,7 @@ module Ruact
154
173
  Ruact::ServerFunctions.write_v2_snapshot!(route_set: routes, root: Pathname.new(@tmpdir))
155
174
  end
156
175
 
157
- let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
176
+ let(:real_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.ts") }
158
177
 
159
178
  it "emits query entries (route-truth) merged into the v2 snapshot" do
160
179
  entries = write!
@@ -162,13 +181,18 @@ module Ruact
162
181
  expect(entries).to all(include("kind" => "query", "http_method" => "GET"))
163
182
  end
164
183
 
165
- it "renders _makeQuery refs + the useQuery re-export into the .next TS" do
184
+ it "renders _makeQuery refs + the useQuery re-export into the TS" do
166
185
  write!
167
- ts = File.read(next_ts)
186
+ ts = File.read(real_ts)
168
187
  expect(ts).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
169
188
  expect(ts).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
170
189
  expect(ts).to include("export const categories: () => Promise<unknown> =")
171
- expect(ts).to include("export const search: (params: Record<string, unknown>) => Promise<unknown> =")
190
+ # Story 13.4 the real pipeline now narrows `params` to the declared
191
+ # kwargs end-to-end (`def search(term:)` → a required `term` of the FR88
192
+ # wire union), not the opaque `Record<string, unknown>`.
193
+ expect(ts).to include(
194
+ "export const search: (params: { term: string | number | boolean | null }) => Promise<unknown> ="
195
+ )
172
196
  expect(ts).to include('export { useQuery } from "ruact/server-functions-runtime";')
173
197
  end
174
198
 
@@ -177,13 +201,9 @@ module Ruact
177
201
  stub_const("CollideQuery", Class.new(Ruact::Query) { def categories; end })
178
202
  rs = ActionDispatch::Routing::RouteSet.new
179
203
  rs.draw do
180
- # POST /categories#create would derive js_identifier "createCategory" — to
181
- # force a clash, use a custom collection route named to collide head-on.
182
204
  post "categories", to: "categories#categories"
183
205
  ruact_queries CollideQuery
184
206
  end
185
- # Rename the action's js_identifier to exactly "categories" so it collides
186
- # with the query method.
187
207
  CategoriesController.define_singleton_method(:__ruact_function_name_overrides) do
188
208
  { "categories" => "categories" }
189
209
  end
@@ -191,222 +211,5 @@ module Ruact
191
211
  .to raise_error(Ruact::ConfigurationError, /both map to JS identifier "categories".*ruact_function_name/m)
192
212
  end
193
213
  end
194
-
195
- RSpec.describe "Ruact::Railtie registry-clear hook (Story 8.1)", :story_8_1 do
196
- # The Railtie attaches a `before_class_unload` callback that clears both
197
- # registries before Zeitwerk tears down constants — this prevents removed
198
- # `ruact_action` declarations from lingering across reloads. The full
199
- # Rails-app boot covering the controller class-body re-evaluation lives
200
- # in `controller_request_spec.rb`; here we exercise the hook directly.
201
- before do
202
- Ruact.action_registry.clear!
203
- Ruact.query_registry.clear!
204
- end
205
-
206
- it "clears both registries when invoked" do
207
- Ruact.action_registry.register(:foo, kind: :action)
208
- Ruact.query_registry.register(:bar, kind: :query)
209
- expect(Ruact.action_registry.size).to eq(1)
210
- expect(Ruact.query_registry.size).to eq(1)
211
-
212
- # Direct invocation of the cleanup that the reloader hook would run.
213
- Ruact.action_registry.clear!
214
- Ruact.query_registry.clear!
215
-
216
- expect(Ruact.action_registry.size).to eq(0)
217
- expect(Ruact.query_registry.size).to eq(0)
218
- end
219
-
220
- it "the snapshot write-if-changed guard skips a rewrite when controllers " \
221
- "re-register the same symbols after a clear (Story 8.1 — pitfall #1 mitigation)" do
222
- Dir.mktmpdir do |dir|
223
- original_root = Rails.root
224
- Rails.root = Pathname.new(dir)
225
-
226
- Ruact.action_registry.register(:create_post, kind: :action)
227
- Ruact::Railtie.write_server_functions_snapshot!
228
- original_bytes = File.read(File.join(dir, "tmp/cache/ruact/server-functions.json"))
229
-
230
- # Simulate a reload cycle: clear, then re-register the same symbol
231
- # with a fresh class object (the same as what would happen when
232
- # controller class bodies re-evaluate after Zeitwerk teardown).
233
- Ruact.action_registry.clear!
234
- Ruact.action_registry.register(:create_post, kind: :action)
235
-
236
- expect(Ruact::Railtie.write_server_functions_snapshot!).to be(false)
237
- expect(File.read(File.join(dir, "tmp/cache/ruact/server-functions.json"))).to eq(original_bytes)
238
- ensure
239
- Rails.root = original_root
240
- end
241
- end
242
- end
243
-
244
- # Story 8.1 Re-run-6/8 — force_load_controllers! now walks Rails::Engine
245
- # subclasses so engine-owned `ruact_action` declarations populate the
246
- # registry at boot (not on first request to the engine controller).
247
- # The regression target: a mounted engine that declares its own controller
248
- # with `ruact_action :engine_action` must be visible to the snapshot
249
- # writer + endpoint dispatcher BEFORE any HTTP traffic.
250
- RSpec.describe "Ruact::Railtie.force_load_controllers! engine scanning (Story 8.1)", :story_8_1 do
251
- before do
252
- Ruact.action_registry.clear!
253
- Ruact.query_registry.clear!
254
-
255
- # In a real Rails boot, `require_dependency` is added to `Object` by
256
- # `ActiveSupport::Dependencies.hook!` before `config.to_prepare`
257
- # fires. The minimal spec-env setup (rails_stub + action_controller
258
- # core) does not invoke the hook, so we stub the call directly to
259
- # delegate to plain `load(file)` — which is sufficient to exercise
260
- # the engine-scanning branch without dragging the full dependencies
261
- # subsystem into the suite.
262
- allow(Ruact::Railtie).to receive(:force_load_dir).and_wrap_original do |_original, dir|
263
- files = Dir.glob("#{dir}/**/*_controller.rb")
264
- files.each { |file| load(file) }
265
- files.length
266
- end
267
- end
268
-
269
- it "loads ruact_action declarations from a mounted Rails::Engine's app/controllers " \
270
- "(re-run-6 #4 / re-run-8 #2 — engine-owned controllers must populate the registry at boot)" do
271
- Dir.mktmpdir do |engine_dir|
272
- # Build the engine's controller file on disk. The file's body
273
- # declares a real `ruact_action` so populating the registry is
274
- # observable (no mocks of the macro itself).
275
- controllers_dir = File.join(engine_dir, "app/controllers")
276
- FileUtils.mkdir_p(controllers_dir)
277
- controller_path = File.join(controllers_dir, "engine_demo_controller.rb")
278
- File.write(controller_path, <<~RUBY)
279
- # frozen_string_literal: true
280
-
281
- class EngineDemoController < ActionController::Base
282
- include Ruact::Controller
283
-
284
- ruact_action(:engine_only_action) { |_params| "from-engine" }
285
- end
286
- RUBY
287
-
288
- # Build a real Rails::Engine subclass whose paths["app/controllers"]
289
- # points at the on-disk controllers directory. `Engine#paths` is
290
- # automatically populated by Rails.
291
- fake_engine = Class.new(Rails::Engine) do
292
- isolate_namespace Module.new
293
- config.paths["app/controllers"] = controllers_dir
294
- end
295
-
296
- # Stub Rails::Engine.subclasses to return JUST our fake engine — the
297
- # host app's own engine class is filtered out inside
298
- # force_load_controllers! by an explicit `engine_class ==
299
- # Rails.application.class` skip.
300
- allow(Rails::Engine).to receive(:subclasses).and_return([fake_engine])
301
-
302
- expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
303
- expect(Ruact.action_registry.entries[:engine_only_action]).not_to be_nil
304
- expect(Ruact.action_registry.entries[:engine_only_action].controller).to be(EngineDemoController)
305
- end
306
- ensure
307
- # `EngineDemoController` is loaded via `require_dependency` against an
308
- # absolute on-disk path; remove the constant so re-runs of the spec
309
- # don't trip the macro's "method already defined" guard.
310
- Object.send(:remove_const, :EngineDemoController) if defined?(EngineDemoController)
311
- end
312
-
313
- it "skips the host application's own Rails::Engine subclass " \
314
- "(avoids double-loading app/controllers already covered by the Rails.application branch)" do
315
- # Rails.application.class IS a Rails::Engine subclass; force_load_controllers!
316
- # iterates the host app FIRST via the application branch, then skips it
317
- # explicitly in the engine branch. Confirm that filtering happens.
318
- host_class = Rails.application.class
319
- allow(Rails::Engine).to receive(:subclasses).and_return([host_class])
320
-
321
- # We expect ZERO additional load operations from the engine branch
322
- # because the only subclass is the host app itself.
323
- expect(Ruact::Railtie).not_to receive(:safe_engine_instance)
324
-
325
- expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
326
- end
327
-
328
- it "swallows a misconfigured engine (engine_class.instance raising) " \
329
- "via safe_engine_instance so a single broken engine cannot block boot" do
330
- bad_engine = Class.new(Rails::Engine)
331
- allow(bad_engine).to receive(:instance).and_raise(StandardError, "engine boot failed")
332
- allow(Rails::Engine).to receive(:subclasses).and_return([bad_engine])
333
-
334
- # Must not propagate; force_load_controllers! returns normally.
335
- expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
336
- end
337
- end
338
-
339
- # Story 8.3 — force_load_server_function_hosts! ALSO walks
340
- # `app/server_actions/**/*.rb` so standalone modules register at boot
341
- # alongside controller-hosted actions. Follows the Story 8.1 fake-engine
342
- # pattern to bypass Rails.application's sticky root memoization.
343
- RSpec.describe "Ruact::Railtie.force_load_server_function_hosts! " \
344
- "app/server_actions/ scanning (Story 8.3)", :story_8_3 do
345
- before do
346
- Ruact.action_registry.clear!
347
- Ruact.query_registry.clear!
348
-
349
- # Same stub as the Story 8.1 engine-scanning describe — substitutes
350
- # `require_dependency` (unavailable in the minimal spec env) with
351
- # plain `load`. Accepts both `dir` (positional) and `glob:` (kwarg).
352
- allow(Ruact::Railtie).to receive(:force_load_dir).and_wrap_original do |_original, dir, glob: "**/*_controller.rb"|
353
- files = Dir.glob("#{dir}/#{glob}")
354
- files.each { |file| load(file) }
355
- files.length
356
- end
357
- end
358
-
359
- it "loads ruact_action declarations from app/server_actions/ at boot " \
360
- "(Pitfall #7 — standalone modules must register before the snapshot writer runs)" do
361
- Dir.mktmpdir do |engine_dir|
362
- server_actions_dir = File.join(engine_dir, "app/server_actions")
363
- FileUtils.mkdir_p(server_actions_dir)
364
- module_path = File.join(server_actions_dir, "standalone_railtie_demo.rb")
365
- File.write(module_path, <<~RUBY)
366
- # frozen_string_literal: true
367
-
368
- module StandaloneRailtieDemo
369
- extend Ruact::ServerAction
370
-
371
- ruact_action(:standalone_railtie_demo) { |_p| "from-standalone" }
372
- end
373
- RUBY
374
-
375
- fake_engine = Class.new(Rails::Engine) do
376
- isolate_namespace Module.new
377
- # Register the path explicitly so `server_actions_paths_for`
378
- # finds it via the Rails paths enumerator.
379
- config.paths.add "app/server_actions", with: server_actions_dir
380
- end
381
-
382
- # Stub Rails::Engine.subclasses to expose ONLY the fake engine —
383
- # the host app's own controllers/server_actions are filtered out
384
- # by the engine_class == Rails.application.class skip inside
385
- # force_load_server_function_hosts!.
386
- allow(Rails::Engine).to receive(:subclasses).and_return([fake_engine])
387
-
388
- expect { Ruact::Railtie.force_load_server_function_hosts! }.not_to raise_error
389
-
390
- entry = Ruact.action_registry.entries[:standalone_railtie_demo]
391
- expect(entry).not_to be_nil
392
- expect(entry.controller).to be(StandaloneRailtieDemo)
393
- expect(entry.controller).to be_a(Module)
394
- expect(entry.controller).not_to be_a(Class)
395
- end
396
- ensure
397
- Object.send(:remove_const, :StandaloneRailtieDemo) if defined?(StandaloneRailtieDemo)
398
- end
399
-
400
- it "silently no-ops when no engine has an app/server_actions/ directory " \
401
- "(typical for apps that only use controller-hosted actions)" do
402
- allow(Rails::Engine).to receive(:subclasses).and_return([])
403
- expect { Ruact::Railtie.force_load_server_function_hosts! }.not_to raise_error
404
- end
405
-
406
- it "back-compat: the old `force_load_controllers!` name aliases to the new method" do
407
- expect(Ruact::Railtie.method(:force_load_controllers!).original_name)
408
- .to eq(:force_load_server_function_hosts!)
409
- end
410
- end
411
214
  end
412
215
  end
@@ -4,17 +4,29 @@ require "spec_helper"
4
4
  require "rake"
5
5
  require "tmpdir"
6
6
  require "fileutils"
7
+ require "action_controller"
8
+ require "ruact/server"
7
9
 
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.
10
+ # Story 9.9 — exercises `rake ruact:server_functions:generate` end-to-end. The
11
+ # task forces the route table to load, then writes the route-driven (v2)
12
+ # snapshot to the REAL bridge (tmp/cache/ruact/server-functions.json) and
13
+ # renders the TS module. It runs against a tmpdir as Rails.root with a stubbed
14
+ # Rails.application providing a route set + a no-op routes_reloader.
15
15
  module Ruact
16
16
  module ServerFunctions
17
- RSpec.describe "rake ruact:server_functions:generate", :story_8_0a do
17
+ RSpec.describe "rake ruact:server_functions:generate", :story_9_9 do
18
+ before do
19
+ stub_const("RakeDemoPostsController", Class.new(ActionController::Base) { include Ruact::Server })
20
+ reloader = Object.new
21
+ def reloader.execute_unless_loaded; end
22
+ rs = ActionDispatch::Routing::RouteSet.new
23
+ rs.draw { resources :rake_demo_posts, only: %i[create update destroy] }
24
+ app = Object.new
25
+ app.define_singleton_method(:routes) { rs }
26
+ app.define_singleton_method(:routes_reloader) { reloader }
27
+ allow(Rails).to receive(:application).and_return(app)
28
+ end
29
+
18
30
  around do |example|
19
31
  Dir.mktmpdir do |dir|
20
32
  original_root = Rails.root
@@ -41,45 +53,33 @@ module Ruact
41
53
  Rake::Task["ruact:server_functions:generate"].invoke
42
54
  end
43
55
 
44
- it "registers the task under the documented name (Story 8.0a)" do
56
+ it "registers the task under the documented name" do
45
57
  expect(Rake.application.lookup("ruact:server_functions:generate")).not_to be_nil
46
58
  end
47
59
 
48
- it "writes both the JSON bridge and the TS module on first run (Story 8.0a)",
49
- :aggregate_failures do
60
+ it "writes both the v2 JSON bridge and the TS module on first run", :aggregate_failures do
50
61
  invoke!
51
62
  expect(File).to exist(json_path)
52
63
  expect(File).to exist(ts_path)
64
+ expect(JSON.parse(File.read(json_path)).fetch("version")).to eq(2)
53
65
  expect(File.read(ts_path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
66
+ expect(File.read(ts_path)).to include("_makeServerFunction({ method: \"POST\"")
54
67
  end
55
68
 
56
- it "is idempotent: a second run does not change file contents (Story 8.0a)" do
69
+ it "is idempotent: a second run does not change file contents" do
57
70
  invoke!
58
71
  before = File.read(ts_path)
59
72
  invoke!
60
73
  expect(File.read(ts_path)).to eq(before)
61
74
  end
62
75
 
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!)
76
+ it "exits 1 with a `[ruact] error:` line when codegen raises a ConfigurationError" do
77
+ allow(Ruact::ServerFunctions).to receive(:write_v2_snapshot!)
78
78
  .and_raise(Ruact::ConfigurationError,
79
- "ruact_action / ruact_query symbol :RECALCULATE must match /^[a-z_][a-z0-9_]*$/")
79
+ "server-function naming collision: PostsController#create and ...")
80
80
  expect { invoke! }
81
81
  .to raise_error(SystemExit)
82
- .and output(/\[ruact\] error:.*RECALCULATE/).to_stderr
82
+ .and output(/\[ruact\] error:.*naming collision/).to_stderr
83
83
  end
84
84
  end
85
85
  end