ruact 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) 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 +88 -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 +1779 -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 +100 -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 +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "ruact/server_functions"
5
+ require "ruact/server_functions/query_source"
6
+
7
+ module Ruact
8
+ module ServerFunctions
9
+ # Plain query classes — only `instance_method(...).parameters` (kwargs
10
+ # presence) and `.name` (collision origin) are read. Defined at module
11
+ # level so they are not example-group-leaky constants.
12
+ class CatalogQ
13
+ def categories; end
14
+
15
+ def search_users(term:, limit: 10); end
16
+ end
17
+
18
+ class PeopleQ
19
+ def search_users(term:); end
20
+ end
21
+
22
+ # Story 9.5 — query introspection: the drawn route table (filtered to the
23
+ # generated query dispatch controllers) → v2 query entries. Pure: route set
24
+ # and the query-class resolver are injected, so the derivation is testable
25
+ # with no Rails boot and no controllers (a fake route set + plain query
26
+ # classes).
27
+ RSpec.describe QuerySource, :story_9_5 do
28
+ # Minimal route double exposing the surface QuerySource reads:
29
+ # `defaults[:controller]`, `defaults[:action]`, `path.spec.to_s`.
30
+ def query_route(controller, action, path)
31
+ spec = Object.new
32
+ spec.define_singleton_method(:to_s) { "#{path}(.:format)" }
33
+ path_obj = Object.new
34
+ path_obj.define_singleton_method(:spec) { spec }
35
+ route = Object.new
36
+ route.define_singleton_method(:defaults) { { controller: controller, action: action } }
37
+ route.define_singleton_method(:path) { path_obj }
38
+ route
39
+ end
40
+
41
+ def route_set(routes)
42
+ rs = Object.new
43
+ rs.define_singleton_method(:routes) { routes }
44
+ rs
45
+ end
46
+
47
+ let(:prefix) { QuerySource::QUERY_CONTROLLER_PREFIX }
48
+
49
+ def resolver(map)
50
+ ->(controller) { map[controller] }
51
+ end
52
+
53
+ describe ".collect" do
54
+ it "emits one query entry per mounted query route (route-truth)" do
55
+ rs = route_set([
56
+ query_route("#{prefix}catalog_q", "categories", "/q/categories"),
57
+ query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
58
+ ])
59
+ entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
60
+
61
+ expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories searchUsers])
62
+ expect(entries).to all(include("kind" => "query", "http_method" => "GET", "segments" => []))
63
+ end
64
+
65
+ it "derives jsId via NameBridge and carries the clean path" do
66
+ rs = route_set([query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")])
67
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
68
+ expect(entry["js_identifier"]).to eq("searchUsers")
69
+ expect(entry["path"]).to eq("/q/searchUsers")
70
+ expect(entry["controller"]).to eq("Ruact::ServerFunctions::CatalogQ")
71
+ expect(entry["action"]).to eq("search_users")
72
+ end
73
+
74
+ it "flags accepts_params true when the method declares kwargs, false otherwise" do
75
+ rs = route_set([
76
+ query_route("#{prefix}catalog_q", "categories", "/q/categories"),
77
+ query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
78
+ ])
79
+ entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
80
+ by_id = entries.to_h { |e| [e["js_identifier"], e] }
81
+ expect(by_id["categories"]["accepts_params"]).to be(false)
82
+ expect(by_id["searchUsers"]["accepts_params"]).to be(true)
83
+ end
84
+
85
+ it "ignores non-query routes (controller not under the dispatch namespace)" do
86
+ rs = route_set([
87
+ query_route("posts", "create", "/posts"),
88
+ query_route("#{prefix}catalog_q", "categories", "/q/categories")
89
+ ])
90
+ entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
91
+ expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories])
92
+ end
93
+
94
+ it "skips a query route whose class cannot be resolved (nil resolver result)" do
95
+ rs = route_set([query_route("#{prefix}gone_q", "categories", "/q/categories")])
96
+ expect(described_class.collect(rs, query_class_for: resolver({}))).to eq([])
97
+ end
98
+
99
+ it "raises a query×query collision naming both origins" do
100
+ rs = route_set([
101
+ query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers"),
102
+ query_route("#{prefix}people_q", "search_users", "/q/searchUsers")
103
+ ])
104
+ map = { "#{prefix}catalog_q" => CatalogQ, "#{prefix}people_q" => PeopleQ }
105
+ expect { described_class.collect(rs, query_class_for: resolver(map)) }
106
+ .to raise_error(Ruact::ConfigurationError, /CatalogQ#search_users and .*PeopleQ#search_users/m)
107
+ end
108
+ end
109
+ end
110
+
111
+ # Story 9.5 (Task 2) — the merged JS namespace (route entries + query
112
+ # entries share it). The route×query side is detected at the codegen
113
+ # combine point.
114
+ RSpec.describe ".detect_merged_namespace_collisions!", :story_9_5 do
115
+ def action_entry(js_id, controller, action)
116
+ { "js_identifier" => js_id, "kind" => "action", "controller" => controller, "action" => action }
117
+ end
118
+
119
+ def query_entry(js_id, controller, action)
120
+ { "js_identifier" => js_id, "kind" => "query", "controller" => controller, "action" => action }
121
+ end
122
+
123
+ it "raises on a route×query collision naming both origins + the rename escape hatch" do
124
+ entries = [
125
+ action_entry("categories", "posts", "categories"),
126
+ query_entry("categories", "CatalogQuery", "categories")
127
+ ]
128
+ expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }
129
+ .to raise_error(Ruact::ConfigurationError,
130
+ /posts#categories and CatalogQuery#categories.*ruact_function_name/m)
131
+ end
132
+
133
+ it "does not raise when the rename makes the identifiers distinct" do
134
+ entries = [
135
+ action_entry("listCategories", "posts", "categories"), # renamed via ruact_function_name
136
+ query_entry("categories", "CatalogQuery", "categories")
137
+ ]
138
+ expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }.not_to raise_error
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,412 @@
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::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
+ around do |example|
135
+ Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
136
+ end
137
+
138
+ before do
139
+ stub_const("V2QueryParentController", Class.new(ActionController::Base))
140
+ Ruact.configure { |c| c.query_parent_controller = "V2QueryParentController" }
141
+ stub_const("V2CatalogQuery", Class.new(Ruact::Query) do
142
+ def categories; end
143
+ def search(term:); end
144
+ end)
145
+ end
146
+
147
+ def route_set
148
+ route_set = ActionDispatch::Routing::RouteSet.new
149
+ route_set.draw { ruact_queries V2CatalogQuery }
150
+ route_set
151
+ end
152
+
153
+ def write!(routes = route_set)
154
+ Ruact::ServerFunctions.write_v2_snapshot!(route_set: routes, root: Pathname.new(@tmpdir))
155
+ end
156
+
157
+ let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
158
+
159
+ it "emits query entries (route-truth) merged into the v2 snapshot" do
160
+ entries = write!
161
+ expect(entries.map { |e| e["js_identifier"] }).to match_array(%w[categories search])
162
+ expect(entries).to all(include("kind" => "query", "http_method" => "GET"))
163
+ end
164
+
165
+ it "renders _makeQuery refs + the useQuery re-export into the .next TS" do
166
+ write!
167
+ ts = File.read(next_ts)
168
+ expect(ts).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
169
+ expect(ts).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
170
+ expect(ts).to include("export const categories: () => Promise<unknown> =")
171
+ expect(ts).to include("export const search: (params: Record<string, unknown>) => Promise<unknown> =")
172
+ expect(ts).to include('export { useQuery } from "ruact/server-functions-runtime";')
173
+ end
174
+
175
+ it "raises a route×query collision when an action and a query share a js_identifier" do
176
+ stub_const("CategoriesController", Class.new(ActionController::Base) { include Ruact::Server })
177
+ stub_const("CollideQuery", Class.new(Ruact::Query) { def categories; end })
178
+ rs = ActionDispatch::Routing::RouteSet.new
179
+ 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
+ post "categories", to: "categories#categories"
183
+ ruact_queries CollideQuery
184
+ end
185
+ # Rename the action's js_identifier to exactly "categories" so it collides
186
+ # with the query method.
187
+ CategoriesController.define_singleton_method(:__ruact_function_name_overrides) do
188
+ { "categories" => "categories" }
189
+ end
190
+ expect { Ruact::ServerFunctions.write_v2_snapshot!(route_set: rs, root: Pathname.new(@tmpdir)) }
191
+ .to raise_error(Ruact::ConfigurationError, /both map to JS identifier "categories".*ruact_function_name/m)
192
+ end
193
+ 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
+ end
412
+ 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