ruact 0.0.5 → 0.0.7
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 +74 -18
- 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 +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- 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 +109 -0
- data/lib/ruact.rb +20 -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 +70 -328
- 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/manifest_resolver_spec.rb +159 -0
- 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 +15 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -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 +58 -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
|
@@ -5,302 +5,61 @@ require "tmpdir"
|
|
|
5
5
|
|
|
6
6
|
module Ruact
|
|
7
7
|
module ServerFunctions
|
|
8
|
-
RSpec.describe Codegen, :
|
|
9
|
-
|
|
8
|
+
RSpec.describe Codegen, :story_9_3 do
|
|
9
|
+
# Story 9.9 — the v1 (registry / `_makeRef`) render path was demolished;
|
|
10
|
+
# only the route-driven (version-2) snapshot is supported.
|
|
11
|
+
let(:v2_base) do
|
|
10
12
|
{
|
|
11
|
-
version:
|
|
12
|
-
generated_at: "2026-
|
|
13
|
+
version: 2,
|
|
14
|
+
generated_at: "2026-06-09T00:00:00Z",
|
|
13
15
|
functions: []
|
|
14
16
|
}
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
describe ".render —
|
|
18
|
-
it "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# `import { revalidate } from "@/.ruact/server-functions"` works in
|
|
22
|
-
# projects that have not yet declared any server actions.
|
|
23
|
-
result = described_class.render(base_snapshot)
|
|
24
|
-
|
|
25
|
-
expect(result).to eq(<<~TS)
|
|
26
|
-
// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.
|
|
27
|
-
// Source: tmp/cache/ruact/server-functions.json (version 1)
|
|
28
|
-
// Generated at: 2026-05-13T12:34:56Z
|
|
29
|
-
import { _makeRef } from "ruact/server-functions-runtime";
|
|
30
|
-
|
|
31
|
-
// (no server functions registered yet — Stories 8.1 / 9.1 populate)
|
|
32
|
-
void _makeRef;
|
|
33
|
-
|
|
34
|
-
export { revalidate } from "ruact/server-functions-runtime";
|
|
35
|
-
TS
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
it "ends with exactly one trailing newline" do
|
|
39
|
-
result = described_class.render(base_snapshot)
|
|
40
|
-
expect(result).to end_with("\n")
|
|
41
|
-
expect(result).not_to end_with("\n\n")
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it "references _makeRef even when empty so noUnusedLocals stays green " \
|
|
45
|
-
"(Re-run patch 2026-05-13)" do
|
|
46
|
-
# Strictly checks that the import is "touched" — the canonical
|
|
47
|
-
# `void _makeRef;` is the lightweight discard pattern.
|
|
48
|
-
expect(described_class.render(base_snapshot)).to include("void _makeRef;")
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
describe ".render — single action (Story 8.0a AC4 + Story 8.2 intersection)" do
|
|
53
|
-
it "emits an action export typed as a TS intersection of direct-call + <form action> shapes" do
|
|
54
|
-
# Story 8.2 (refined 2026-05-17 per review patch R1) — the
|
|
55
|
-
# intersection makes `<form action={createPost}>` typecheck
|
|
56
|
-
# directly against React 19's
|
|
57
|
-
# `(formData: FormData) => void | Promise<void>` while preserving
|
|
58
|
-
# `Promise<unknown>` for direct callers. See the 2026-05-17 entry
|
|
59
|
-
# in `gem/docs/internal/decisions/server-functions-api.md`.
|
|
60
|
-
snapshot = base_snapshot.merge(functions: [
|
|
61
|
-
{
|
|
62
|
-
"ruby_symbol" => "create_post",
|
|
63
|
-
"js_identifier" => "createPost",
|
|
64
|
-
"kind" => "action",
|
|
65
|
-
"controller" => "PostsController"
|
|
66
|
-
}
|
|
67
|
-
])
|
|
68
|
-
|
|
69
|
-
expect(described_class.render(snapshot)).to eq(<<~TS)
|
|
70
|
-
// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.
|
|
71
|
-
// Source: tmp/cache/ruact/server-functions.json (version 1)
|
|
72
|
-
// Generated at: 2026-05-13T12:34:56Z
|
|
73
|
-
import { _makeRef } from "ruact/server-functions-runtime";
|
|
74
|
-
|
|
75
|
-
export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>) =
|
|
76
|
-
_makeRef("create_post");
|
|
77
|
-
|
|
78
|
-
export { revalidate } from "ruact/server-functions-runtime";
|
|
79
|
-
TS
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
it "Story 8.2 — query signatures do NOT widen (regression guard)" do
|
|
83
|
-
snapshot = base_snapshot.merge(functions: [
|
|
84
|
-
{
|
|
85
|
-
"ruby_symbol" => "categories",
|
|
86
|
-
"js_identifier" => "categories",
|
|
87
|
-
"kind" => "query",
|
|
88
|
-
"controller" => "CategoriesController"
|
|
89
|
-
}
|
|
90
|
-
])
|
|
91
|
-
|
|
92
|
-
out = described_class.render(snapshot)
|
|
93
|
-
# Action-style FormData widening must not bleed into the query export
|
|
94
|
-
expect(out).not_to match(/export const categories:[^=]*FormData/)
|
|
95
|
-
expect(out).to include("export const categories: () => Promise<unknown> =")
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
describe ".render — single query (Story 8.0a AC4)" do
|
|
100
|
-
it "emits a query export with the no-args signature" do
|
|
101
|
-
snapshot = base_snapshot.merge(functions: [
|
|
102
|
-
{
|
|
103
|
-
"ruby_symbol" => "categories",
|
|
104
|
-
"js_identifier" => "categories",
|
|
105
|
-
"kind" => "query",
|
|
106
|
-
"controller" => "CategoriesController"
|
|
107
|
-
}
|
|
108
|
-
])
|
|
109
|
-
|
|
110
|
-
expect(described_class.render(snapshot)).to include(
|
|
111
|
-
"export const categories: () => Promise<unknown> =\n _makeRef(\"categories\");\n"
|
|
112
|
-
)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
describe ".render — mixed action and query (Story 8.0a)" do
|
|
117
|
-
it "emits both shapes in the order provided by the snapshot" do
|
|
118
|
-
snapshot = base_snapshot.merge(functions: [
|
|
119
|
-
{ "ruby_symbol" => "create_post",
|
|
120
|
-
"js_identifier" => "createPost",
|
|
121
|
-
"kind" => "action",
|
|
122
|
-
"controller" => "PostsController" },
|
|
123
|
-
{ "ruby_symbol" => "categories",
|
|
124
|
-
"js_identifier" => "categories",
|
|
125
|
-
"kind" => "query",
|
|
126
|
-
"controller" => "CategoriesController" }
|
|
127
|
-
])
|
|
128
|
-
|
|
129
|
-
out = described_class.render(snapshot)
|
|
130
|
-
expect(out).to include("export const createPost:")
|
|
131
|
-
expect(out).to include("export const categories:")
|
|
132
|
-
expect(out.index("createPost")).to be < out.index("categories")
|
|
19
|
+
describe ".render — unsupported / malformed snapshots" do
|
|
20
|
+
it "rejects when snapshot is not a Hash" do
|
|
21
|
+
expect { described_class.render("oops") }
|
|
22
|
+
.to raise_error(Ruact::ConfigurationError, /snapshot must be a Hash/)
|
|
133
23
|
end
|
|
134
|
-
end
|
|
135
24
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(first).to eq(second)
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
describe ".render — accepts symbol-keyed and string-keyed function entries" do
|
|
145
|
-
it "tolerates either key style" do
|
|
146
|
-
string_keys = base_snapshot.merge(functions: [
|
|
147
|
-
{ "ruby_symbol" => "demo_ping",
|
|
148
|
-
"js_identifier" => "demoPing",
|
|
149
|
-
"kind" => "action" }
|
|
150
|
-
])
|
|
151
|
-
symbol_keys = base_snapshot.merge(functions: [
|
|
152
|
-
{ ruby_symbol: "demo_ping",
|
|
153
|
-
js_identifier: "demoPing",
|
|
154
|
-
kind: "action" }
|
|
155
|
-
])
|
|
156
|
-
expect(described_class.render(string_keys)).to eq(described_class.render(symbol_keys))
|
|
25
|
+
it "wraps a missing root key as Ruact::ConfigurationError" do
|
|
26
|
+
evil = { generated_at: "2026-05-14T00:00:00Z", functions: [] } # no :version
|
|
27
|
+
expect { described_class.render(evil) }
|
|
28
|
+
.to raise_error(Ruact::ConfigurationError, /missing required key/)
|
|
157
29
|
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
describe ".render — snapshot trust-boundary guards (Re-run patch 2026-05-13)" do
|
|
161
|
-
it "rejects a snapshot entry whose js_identifier is not a valid JS identifier " \
|
|
162
|
-
"(would otherwise inject TS at module top level)" do
|
|
163
|
-
snapshot = base_snapshot.merge(functions: [
|
|
164
|
-
{ "ruby_symbol" => "create_post",
|
|
165
|
-
"js_identifier" => ");\nevil();_makeRef(\"x",
|
|
166
|
-
"kind" => "action" }
|
|
167
|
-
])
|
|
168
|
-
expect { described_class.render(snapshot) }
|
|
169
|
-
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
170
|
-
expect(error.message).to include("snapshot")
|
|
171
|
-
expect(error.message).to include("valid JS identifier")
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
it "rejects an entry with a non-String js_identifier" do
|
|
176
|
-
snapshot = base_snapshot.merge(functions: [
|
|
177
|
-
{ "ruby_symbol" => "create_post",
|
|
178
|
-
"js_identifier" => nil,
|
|
179
|
-
"kind" => "action" }
|
|
180
|
-
])
|
|
181
|
-
expect { described_class.render(snapshot) }
|
|
182
|
-
.to raise_error(Ruact::ConfigurationError, /valid JS identifier/)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
it "JSON-escapes the ruby_symbol argument to _makeRef so backslashes / quotes " \
|
|
186
|
-
"in a (hand-edited or corrupted) snapshot cannot break out of the string literal" do
|
|
187
|
-
# The js_identifier still has to be a valid identifier — only the
|
|
188
|
-
# ruby_symbol can carry arbitrary string content. Escape it.
|
|
189
|
-
snapshot = base_snapshot.merge(functions: [
|
|
190
|
-
{ "ruby_symbol" => "weird\"\\name",
|
|
191
|
-
"js_identifier" => "weirdName",
|
|
192
|
-
"kind" => "action" }
|
|
193
|
-
])
|
|
194
|
-
out = described_class.render(snapshot)
|
|
195
|
-
expect(out).to include('_makeRef("weird\"\\\\name");')
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
describe ".render — snapshot trust-boundary guards (Re-run patch 2026-05-14)" do
|
|
200
|
-
# The Ruby renderer reads the same on-disk JSON bridge as the JS-side
|
|
201
|
-
# renderer (rake task path + Railtie path), so the same guards must
|
|
202
|
-
# apply on both sides. Mirrors the JS-side `validateSnapshot`.
|
|
203
30
|
|
|
204
31
|
it "rejects a snapshot whose version contains a line break" do
|
|
205
|
-
evil =
|
|
32
|
+
evil = v2_base.merge(version: "2\n// injected")
|
|
206
33
|
expect { described_class.render(evil) }
|
|
207
34
|
.to raise_error(Ruact::ConfigurationError, /version.*line break/)
|
|
208
35
|
end
|
|
209
36
|
|
|
210
37
|
it "rejects a snapshot whose generated_at contains a line break" do
|
|
211
|
-
evil =
|
|
38
|
+
evil = v2_base.merge(generated_at: "2026-05-14\n// injected")
|
|
212
39
|
expect { described_class.render(evil) }
|
|
213
40
|
.to raise_error(Ruact::ConfigurationError, /generated_at.*line break/)
|
|
214
41
|
end
|
|
215
42
|
|
|
216
|
-
it "rejects a snapshot whose
|
|
217
|
-
evil =
|
|
218
|
-
expect { described_class.render(evil) }
|
|
219
|
-
.to raise_error(Ruact::ConfigurationError, /functions must be an Array/)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
it "rejects a snapshot entry whose kind is not in the allowlist" do
|
|
223
|
-
evil = base_snapshot.merge(functions: [
|
|
224
|
-
{ "ruby_symbol" => "foo",
|
|
225
|
-
"js_identifier" => "foo",
|
|
226
|
-
"kind" => "mutation" }
|
|
227
|
-
])
|
|
228
|
-
expect { described_class.render(evil) }
|
|
229
|
-
.to raise_error(Ruact::ConfigurationError, /invalid kind/)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
it "rejects a snapshot whose entries duplicate a js_identifier" do
|
|
233
|
-
evil = base_snapshot.merge(functions: [
|
|
234
|
-
{ "ruby_symbol" => "foo",
|
|
235
|
-
"js_identifier" => "foo",
|
|
236
|
-
"kind" => "action" },
|
|
237
|
-
{ "ruby_symbol" => "bar",
|
|
238
|
-
"js_identifier" => "foo",
|
|
239
|
-
"kind" => "query" }
|
|
240
|
-
])
|
|
241
|
-
expect { described_class.render(evil) }
|
|
242
|
-
.to raise_error(Ruact::ConfigurationError, /duplicate js_identifier "foo"/)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
it "rejects a snapshot whose js_identifier is a JS reserved word" do
|
|
246
|
-
evil = base_snapshot.merge(functions: [
|
|
247
|
-
{ "ruby_symbol" => "delete",
|
|
248
|
-
"js_identifier" => "delete",
|
|
249
|
-
"kind" => "action" }
|
|
250
|
-
])
|
|
251
|
-
expect { described_class.render(evil) }
|
|
252
|
-
.to raise_error(Ruact::ConfigurationError, /reserved JS word/)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
it "rejects a snapshot whose version contains a U+2028 line separator " \
|
|
256
|
-
"(Pass-2 patch 2026-05-14 — JS LineTerminator parity)" do
|
|
257
|
-
evil = base_snapshot.merge(version: "1
// injected")
|
|
43
|
+
it "rejects a snapshot whose version contains a U+2028 line separator (JS LineTerminator parity)" do
|
|
44
|
+
evil = v2_base.merge(version: "2
// injected")
|
|
258
45
|
expect { described_class.render(evil) }
|
|
259
46
|
.to raise_error(Ruact::ConfigurationError, /line break.*U\+2028/)
|
|
260
47
|
end
|
|
261
48
|
|
|
262
|
-
it "rejects a snapshot whose generated_at contains a U+2029 paragraph separator "
|
|
263
|
-
|
|
264
|
-
evil = base_snapshot.merge(generated_at: "2026-05-14
// injected")
|
|
49
|
+
it "rejects a snapshot whose generated_at contains a U+2029 paragraph separator (JS LineTerminator parity)" do
|
|
50
|
+
evil = v2_base.merge(generated_at: "2026-05-14
// injected")
|
|
265
51
|
expect { described_class.render(evil) }
|
|
266
52
|
.to raise_error(Ruact::ConfigurationError, /line break.*U\+2029/)
|
|
267
53
|
end
|
|
268
54
|
|
|
269
|
-
it "
|
|
270
|
-
|
|
271
|
-
evil = { generated_at: "2026-05-14T00:00:00Z", functions: [] } # no :version
|
|
272
|
-
expect { described_class.render(evil) }
|
|
273
|
-
.to raise_error(Ruact::ConfigurationError, /missing required key/)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
it "rejects an entry with empty ruby_symbol so we never emit _makeRef(\"\") " \
|
|
277
|
-
"(Pass-2 patch 2026-05-14)" do
|
|
278
|
-
evil = base_snapshot.merge(functions: [
|
|
279
|
-
{ "ruby_symbol" => "",
|
|
280
|
-
"js_identifier" => "foo",
|
|
281
|
-
"kind" => "action" }
|
|
282
|
-
])
|
|
283
|
-
expect { described_class.render(evil) }
|
|
284
|
-
.to raise_error(Ruact::ConfigurationError, /missing or empty ruby_symbol/)
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
it "rejects an entry with nil ruby_symbol (Pass-2 patch 2026-05-14)" do
|
|
288
|
-
evil = base_snapshot.merge(functions: [
|
|
289
|
-
{ "ruby_symbol" => nil,
|
|
290
|
-
"js_identifier" => "foo",
|
|
291
|
-
"kind" => "action" }
|
|
292
|
-
])
|
|
55
|
+
it "rejects an unsupported (non-v2) snapshot version" do
|
|
56
|
+
evil = v2_base.merge(version: 1)
|
|
293
57
|
expect { described_class.render(evil) }
|
|
294
|
-
.to raise_error(Ruact::ConfigurationError, /
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
it "rejects when snapshot is not a Hash" do
|
|
298
|
-
expect { described_class.render("oops") }
|
|
299
|
-
.to raise_error(Ruact::ConfigurationError, /snapshot must be a Hash/)
|
|
58
|
+
.to raise_error(Ruact::ConfigurationError, /unsupported snapshot version/)
|
|
300
59
|
end
|
|
301
60
|
end
|
|
302
61
|
|
|
303
|
-
describe ".generate_ts! (
|
|
62
|
+
describe ".generate_ts! (write-if-changed wrapper)" do
|
|
304
63
|
around do |example|
|
|
305
64
|
Dir.mktmpdir do |dir|
|
|
306
65
|
@tmpdir = dir
|
|
@@ -311,14 +70,14 @@ module Ruact
|
|
|
311
70
|
let(:path) { File.join(@tmpdir, "server-functions.ts") }
|
|
312
71
|
|
|
313
72
|
it "writes the file on first call and returns true" do
|
|
314
|
-
expect(described_class.generate_ts!(snapshot:
|
|
73
|
+
expect(described_class.generate_ts!(snapshot: v2_base, output_path: path))
|
|
315
74
|
.to be(true)
|
|
316
75
|
expect(File.read(path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
|
|
317
76
|
end
|
|
318
77
|
|
|
319
|
-
it "does NOT rewrite when the content is byte-identical
|
|
320
|
-
described_class.generate_ts!(snapshot:
|
|
321
|
-
expect(described_class.generate_ts!(snapshot:
|
|
78
|
+
it "does NOT rewrite when the content is byte-identical" do
|
|
79
|
+
described_class.generate_ts!(snapshot: v2_base, output_path: path)
|
|
80
|
+
expect(described_class.generate_ts!(snapshot: v2_base, output_path: path))
|
|
322
81
|
.to be(false)
|
|
323
82
|
end
|
|
324
83
|
end
|
|
@@ -503,6 +262,136 @@ module Ruact
|
|
|
503
262
|
expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
|
|
504
263
|
end
|
|
505
264
|
end
|
|
265
|
+
|
|
266
|
+
describe ".render — typed query params (Story 13.4)", :story_13_4 do
|
|
267
|
+
def typed_query(js_id, path, params:, params_rest: false)
|
|
268
|
+
{
|
|
269
|
+
"js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
|
|
270
|
+
"path" => path, "segments" => [],
|
|
271
|
+
"accepts_params" => !params.empty? || params_rest,
|
|
272
|
+
"params" => params, "params_rest" => params_rest,
|
|
273
|
+
"controller" => "CatalogQuery", "action" => js_id
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
let(:union) { "string | number | boolean | null" }
|
|
278
|
+
|
|
279
|
+
it "emits a typed params object (required + optional, declaration order)" do
|
|
280
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
281
|
+
typed_query("searchUsers", "/q/searchUsers", params: [
|
|
282
|
+
{ "name" => "term", "required" => true },
|
|
283
|
+
{ "name" => "limit", "required" => false }
|
|
284
|
+
])
|
|
285
|
+
])
|
|
286
|
+
expect(out).to include(
|
|
287
|
+
"export const searchUsers: (params: { term: #{union}; limit?: #{union} }) => Promise<unknown> ="
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
it "emits the bare () signature for a query with empty params + no rest" do
|
|
292
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
293
|
+
typed_query("categories", "/q/categories", params: [])
|
|
294
|
+
])
|
|
295
|
+
expect(out).to include("export const categories: () => Promise<unknown> =")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it "intersects named keys with Record<string, unknown> for a **keyrest query" do
|
|
299
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
300
|
+
typed_query("withRest", "/q/withRest",
|
|
301
|
+
params: [{ "name" => "scope", "required" => true }],
|
|
302
|
+
params_rest: true)
|
|
303
|
+
])
|
|
304
|
+
expect(out).to include(
|
|
305
|
+
"export const withRest: (params: { scope: #{union} } & Record<string, unknown>) => Promise<unknown> ="
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "keeps the open Record<string, unknown> for a **keyrest-only query" do
|
|
310
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
311
|
+
typed_query("restOnly", "/q/restOnly", params: [], params_rest: true)
|
|
312
|
+
])
|
|
313
|
+
expect(out).to include(
|
|
314
|
+
"export const restOnly: (params: Record<string, unknown>) => Promise<unknown> ="
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it "quotes a param key that is not a valid bare JS identifier" do
|
|
319
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
320
|
+
typed_query("oddKey", "/q/oddKey",
|
|
321
|
+
params: [{ "name" => "weird-key", "required" => false }])
|
|
322
|
+
])
|
|
323
|
+
expect(out).to include("(params: { \"weird-key\"?: #{union} }) => Promise<unknown>")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "falls back to the accepts_params boolean when no params metadata is present" do
|
|
327
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
328
|
+
{ "js_identifier" => "legacyQuery", "kind" => "query",
|
|
329
|
+
"http_method" => "GET", "path" => "/q/legacyQuery",
|
|
330
|
+
"segments" => [], "accepts_params" => true }
|
|
331
|
+
])
|
|
332
|
+
expect(out).to include(
|
|
333
|
+
"export const legacyQuery: (params: Record<string, unknown>) => Promise<unknown> ="
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "rejects params that are not an Array (trust boundary)" do
|
|
338
|
+
evil = typed_query("x", "/q/x", params: []).merge("params" => "oops")
|
|
339
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
340
|
+
.to raise_error(Ruact::ConfigurationError, /invalid.*params/)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "rejects a params element that is not an object" do
|
|
344
|
+
evil = typed_query("x", "/q/x", params: ["nope"])
|
|
345
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
346
|
+
.to raise_error(Ruact::ConfigurationError, /params element that is not an object/)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it "rejects a params name containing a line break (TS injection guard)" do
|
|
350
|
+
evil = typed_query("x", "/q/x", params: [{ "name" => "a\n// injected", "required" => true }])
|
|
351
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
352
|
+
.to raise_error(Ruact::ConfigurationError, /non-empty single-line String/)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "rejects a params name that is empty" do
|
|
356
|
+
evil = typed_query("x", "/q/x", params: [{ "name" => "", "required" => true }])
|
|
357
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
358
|
+
.to raise_error(Ruact::ConfigurationError, /non-empty single-line String/)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
it "rejects a non-Boolean required" do
|
|
362
|
+
evil = typed_query("x", "/q/x", params: [{ "name" => "q", "required" => "yes" }])
|
|
363
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
364
|
+
.to raise_error(Ruact::ConfigurationError, /non-Boolean .*required/)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it "rejects a non-Boolean params_rest (would silently over-widen the type)" do
|
|
368
|
+
evil = typed_query("x", "/q/x", params: []).merge("params_rest" => "true")
|
|
369
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
370
|
+
.to raise_error(Ruact::ConfigurationError, /non-Boolean params_rest/)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
it "accepts a legitimate required: false (not mis-read as absent)" do
|
|
374
|
+
expect do
|
|
375
|
+
described_class.render(version: 2, generated_at: "t", functions: [
|
|
376
|
+
typed_query("q1", "/q/q1", params: [{ "name" => "limit", "required" => false }])
|
|
377
|
+
])
|
|
378
|
+
end.not_to raise_error
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it "is byte-stable: regenerating a typed-query snapshot writes nothing the second pass" do
|
|
382
|
+
Dir.mktmpdir do |dir|
|
|
383
|
+
path = File.join(dir, "server-functions.ts")
|
|
384
|
+
snapshot = { version: 2, generated_at: "t", functions: [
|
|
385
|
+
typed_query("searchUsers", "/q/searchUsers", params: [
|
|
386
|
+
{ "name" => "term", "required" => true },
|
|
387
|
+
{ "name" => "limit", "required" => false }
|
|
388
|
+
])
|
|
389
|
+
] }
|
|
390
|
+
expect(described_class.generate_ts!(snapshot: snapshot, output_path: path)).to be(true)
|
|
391
|
+
expect(described_class.generate_ts!(snapshot: snapshot, output_path: path)).to be(false)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
506
395
|
end
|
|
507
396
|
end
|
|
508
397
|
end
|
|
@@ -36,7 +36,7 @@ module Ruact
|
|
|
36
36
|
expect { described_class.to_js_identifier(:CreatePost) }
|
|
37
37
|
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
38
38
|
expect(error.message).to include(":CreatePost")
|
|
39
|
-
expect(error.message).to include("
|
|
39
|
+
expect(error.message).to include("ruact server-function name")
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -150,10 +150,10 @@ module Ruact
|
|
|
150
150
|
|
|
151
151
|
describe "Ruact-reserved names (Story 8.2 review patch R2 — 2026-05-17)", :story_8_2 do
|
|
152
152
|
# The codegen unconditionally re-exports certain runtime helpers
|
|
153
|
-
# from `@/.ruact/server-functions` (e.g. `revalidate`). A
|
|
154
|
-
# `
|
|
153
|
+
# from `@/.ruact/server-functions` (e.g. `revalidate`). A server
|
|
154
|
+
# function named `revalidate` would emit `export const revalidate`
|
|
155
155
|
# next to the helper re-export and crash at module load with a
|
|
156
|
-
# duplicate-export error. Reject at
|
|
156
|
+
# duplicate-export error. Reject at route-draw instead.
|
|
157
157
|
it "rejects :revalidate because it collides with the unconditional helper re-export" do
|
|
158
158
|
expect { described_class.to_js_identifier(:revalidate) }
|
|
159
159
|
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
@@ -170,16 +170,16 @@ module Ruact
|
|
|
170
170
|
expect(described_class.to_js_identifier(:_revalidate)).to eq("_revalidate")
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
-
it "
|
|
174
|
-
expect { described_class.to_js_identifier(:
|
|
173
|
+
it "rejects :_make_server_function because it collides with the codegen's runtime import" do
|
|
174
|
+
expect { described_class.to_js_identifier(:_make_server_function) }
|
|
175
175
|
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
176
|
-
expect(error.message).to include(":
|
|
176
|
+
expect(error.message).to include(":_make_server_function")
|
|
177
177
|
expect(error.message).to include("duplicate export")
|
|
178
178
|
end
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
-
it "
|
|
182
|
-
expect(described_class.to_js_identifier(:
|
|
181
|
+
it "accepts :_make_ref now that the demolished v1 runtime export is no longer reserved (Story 9.9)" do
|
|
182
|
+
expect(described_class.to_js_identifier(:_make_ref)).to eq("_makeRef")
|
|
183
183
|
end
|
|
184
184
|
|
|
185
185
|
# Story 9.5 — `_makeQuery` (the v2 query import) and `useQuery` (the
|
|
@@ -19,6 +19,16 @@ module Ruact
|
|
|
19
19
|
def search_users(term:); end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Story 13.4 — kwarg shapes exercising the per-param metadata derivation:
|
|
23
|
+
# a `**keyrest` (anonymous + named), and a positional that must be ignored.
|
|
24
|
+
class ParamShapesQ
|
|
25
|
+
def with_rest(scope:, **opts); end
|
|
26
|
+
|
|
27
|
+
def rest_only(**opts); end
|
|
28
|
+
|
|
29
|
+
def positional_ignored(id, query:); end
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
# Story 9.5 — query introspection: the drawn route table (filtered to the
|
|
23
33
|
# generated query dispatch controllers) → v2 query entries. Pure: route set
|
|
24
34
|
# and the query-class resolver are injected, so the derivation is testable
|
|
@@ -82,6 +92,47 @@ module Ruact
|
|
|
82
92
|
expect(by_id["searchUsers"]["accepts_params"]).to be(true)
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
it "derives per-kwarg params metadata (name + required/optional, declaration order)", :story_13_4 do
|
|
96
|
+
rs = route_set([query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")])
|
|
97
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
|
|
98
|
+
expect(entry["params"]).to eq([
|
|
99
|
+
{ "name" => "term", "required" => true },
|
|
100
|
+
{ "name" => "limit", "required" => false }
|
|
101
|
+
])
|
|
102
|
+
expect(entry["params_rest"]).to be(false)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "emits empty params + accepts_params false for a no-kwargs query", :story_13_4 do
|
|
106
|
+
rs = route_set([query_route("#{prefix}catalog_q", "categories", "/q/categories")])
|
|
107
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
|
|
108
|
+
expect(entry["params"]).to eq([])
|
|
109
|
+
expect(entry["params_rest"]).to be(false)
|
|
110
|
+
expect(entry["accepts_params"]).to be(false)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "flags params_rest for a `**keyrest` and still carries the named keys", :story_13_4 do
|
|
114
|
+
rs = route_set([query_route("#{prefix}params_q", "with_rest", "/q/withRest")])
|
|
115
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}params_q" => ParamShapesQ)).first
|
|
116
|
+
expect(entry["params"]).to eq([{ "name" => "scope", "required" => true }])
|
|
117
|
+
expect(entry["params_rest"]).to be(true)
|
|
118
|
+
expect(entry["accepts_params"]).to be(true)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "treats a `**keyrest`-only query as accepts_params (rest, no named keys)", :story_13_4 do
|
|
122
|
+
rs = route_set([query_route("#{prefix}params_q", "rest_only", "/q/restOnly")])
|
|
123
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}params_q" => ParamShapesQ)).first
|
|
124
|
+
expect(entry["params"]).to eq([])
|
|
125
|
+
expect(entry["params_rest"]).to be(true)
|
|
126
|
+
expect(entry["accepts_params"]).to be(true)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "ignores positional params (only kwargs are FR88 query params)", :story_13_4 do
|
|
130
|
+
rs = route_set([query_route("#{prefix}params_q", "positional_ignored", "/q/positionalIgnored")])
|
|
131
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}params_q" => ParamShapesQ)).first
|
|
132
|
+
expect(entry["params"]).to eq([{ "name" => "query", "required" => true }])
|
|
133
|
+
expect(entry["params_rest"]).to be(false)
|
|
134
|
+
end
|
|
135
|
+
|
|
85
136
|
it "ignores non-query routes (controller not under the dispatch namespace)" do
|
|
86
137
|
rs = route_set([
|
|
87
138
|
query_route("posts", "create", "/posts"),
|