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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- 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
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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!", :
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
Ruact::Railtie.write_server_functions_snapshot!
|
|
53
|
-
expect(
|
|
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 "
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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(
|
|
100
|
-
expect(File).to exist(
|
|
101
|
-
|
|
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
|
|
130
|
+
it "renders _makeServerFunction calls targeting real routes into the TS" do
|
|
107
131
|
write!
|
|
108
|
-
ts = File.read(
|
|
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(
|
|
140
|
+
first = File.read(real_ts)
|
|
117
141
|
write!
|
|
118
|
-
expect(File.read(
|
|
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(:
|
|
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
|
|
184
|
+
it "renders _makeQuery refs + the useQuery re-export into the TS" do
|
|
166
185
|
write!
|
|
167
|
-
ts = File.read(
|
|
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
|
-
|
|
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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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", :
|
|
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
|
|
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
|
|
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
|
|
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 "
|
|
64
|
-
Ruact.
|
|
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
|
-
"
|
|
79
|
+
"server-function naming collision: PostsController#create and ...")
|
|
80
80
|
expect { invoke! }
|
|
81
81
|
.to raise_error(SystemExit)
|
|
82
|
-
.and output(/\[ruact\] error:.*
|
|
82
|
+
.and output(/\[ruact\] error:.*naming collision/).to_stderr
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
end
|