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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
RSpec.describe Codegen, :story_8_0a do
|
|
9
|
+
let(:base_snapshot) do
|
|
10
|
+
{
|
|
11
|
+
version: 1,
|
|
12
|
+
generated_at: "2026-05-13T12:34:56Z",
|
|
13
|
+
functions: []
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe ".render — empty registry (Story 8.0a AC4)" do
|
|
18
|
+
it "emits a valid module with import line and the empty-registry comment" do
|
|
19
|
+
# Story 8.2 — the empty-registry branch also emits the
|
|
20
|
+
# `export { revalidate } from "..."` re-export so
|
|
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")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe ".render — byte-stable across calls (Story 8.0a)" do
|
|
137
|
+
it "produces byte-identical output for identical snapshots" do
|
|
138
|
+
first = described_class.render(base_snapshot)
|
|
139
|
+
second = described_class.render(base_snapshot.dup)
|
|
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))
|
|
157
|
+
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
|
+
|
|
204
|
+
it "rejects a snapshot whose version contains a line break" do
|
|
205
|
+
evil = base_snapshot.merge(version: "1\n// injected")
|
|
206
|
+
expect { described_class.render(evil) }
|
|
207
|
+
.to raise_error(Ruact::ConfigurationError, /version.*line break/)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it "rejects a snapshot whose generated_at contains a line break" do
|
|
211
|
+
evil = base_snapshot.merge(generated_at: "2026-05-14\n// injected")
|
|
212
|
+
expect { described_class.render(evil) }
|
|
213
|
+
.to raise_error(Ruact::ConfigurationError, /generated_at.*line break/)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "rejects a snapshot whose functions field is not an Array" do
|
|
217
|
+
evil = base_snapshot.merge(functions: "oops")
|
|
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")
|
|
258
|
+
expect { described_class.render(evil) }
|
|
259
|
+
.to raise_error(Ruact::ConfigurationError, /line break.*U\+2028/)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
it "rejects a snapshot whose generated_at contains a U+2029 paragraph separator " \
|
|
263
|
+
"(Pass-2 patch 2026-05-14 — JS LineTerminator parity)" do
|
|
264
|
+
evil = base_snapshot.merge(generated_at: "2026-05-14
// injected")
|
|
265
|
+
expect { described_class.render(evil) }
|
|
266
|
+
.to raise_error(Ruact::ConfigurationError, /line break.*U\+2029/)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "wraps Hash#fetch KeyError as Ruact::ConfigurationError when a root key is " \
|
|
270
|
+
"missing (Pass-2 patch 2026-05-14)" do
|
|
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
|
+
])
|
|
293
|
+
expect { described_class.render(evil) }
|
|
294
|
+
.to raise_error(Ruact::ConfigurationError, /missing or empty ruby_symbol/)
|
|
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/)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe ".generate_ts! (Story 8.0a — write-if-changed wrapper)" do
|
|
304
|
+
around do |example|
|
|
305
|
+
Dir.mktmpdir do |dir|
|
|
306
|
+
@tmpdir = dir
|
|
307
|
+
example.run
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
let(:path) { File.join(@tmpdir, "server-functions.ts") }
|
|
312
|
+
|
|
313
|
+
it "writes the file on first call and returns true" do
|
|
314
|
+
expect(described_class.generate_ts!(snapshot: base_snapshot, output_path: path))
|
|
315
|
+
.to be(true)
|
|
316
|
+
expect(File.read(path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "does NOT rewrite when the content is byte-identical (Story 8.0a)" do
|
|
320
|
+
described_class.generate_ts!(snapshot: base_snapshot, output_path: path)
|
|
321
|
+
expect(described_class.generate_ts!(snapshot: base_snapshot, output_path: path))
|
|
322
|
+
.to be(false)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
describe ".render — route-driven v2 snapshot (Story 9.3)", :story_9_3 do
|
|
327
|
+
let(:v2_snapshot) do
|
|
328
|
+
{
|
|
329
|
+
version: 2,
|
|
330
|
+
generated_at: "2026-06-09T00:00:00Z",
|
|
331
|
+
functions: [
|
|
332
|
+
{ "js_identifier" => "createPost", "kind" => "action",
|
|
333
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [],
|
|
334
|
+
"controller" => "posts", "action" => "create" },
|
|
335
|
+
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
336
|
+
"http_method" => "PATCH", "path" => "/posts/:id", "segments" => ["id"],
|
|
337
|
+
"controller" => "posts", "action" => "update" }
|
|
338
|
+
]
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it "dispatches on version 2 and emits _makeServerFunction with real path+verb" do
|
|
343
|
+
out = described_class.render(v2_snapshot)
|
|
344
|
+
expect(out).to include('import { _makeServerFunction } from "ruact/server-functions-runtime";')
|
|
345
|
+
expect(out).to include('_makeServerFunction({ method: "POST", path: "/posts", segments: [] });')
|
|
346
|
+
expect(out).to include('_makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });')
|
|
347
|
+
expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it "keeps the Story 8.2 intersection signature on v2 actions (form action support)" do
|
|
351
|
+
out = described_class.render(v2_snapshot)
|
|
352
|
+
expect(out).to include("& ((formData: FormData) => Promise<void>)")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "emits the empty-v2 module when no routes are exposed" do
|
|
356
|
+
out = described_class.render(version: 2, generated_at: "2026-06-09T00:00:00Z", functions: [])
|
|
357
|
+
expect(out).to include("void _makeServerFunction;")
|
|
358
|
+
expect(out).to include("(no server functions exposed yet")
|
|
359
|
+
expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "rejects a v2 entry with an invalid http_method" do
|
|
363
|
+
evil = v2_snapshot.merge(functions: [
|
|
364
|
+
{ "js_identifier" => "createPost", "kind" => "action",
|
|
365
|
+
"http_method" => "GET", "path" => "/posts", "segments" => [] }
|
|
366
|
+
])
|
|
367
|
+
expect { described_class.render(evil) }
|
|
368
|
+
.to raise_error(Ruact::ConfigurationError, /invalid http_method/)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it "rejects a v2 entry declaring a segment absent from the path" do
|
|
372
|
+
evil = v2_snapshot.merge(functions: [
|
|
373
|
+
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
374
|
+
"http_method" => "PATCH", "path" => "/posts", "segments" => ["id"] }
|
|
375
|
+
])
|
|
376
|
+
expect { described_class.render(evil) }
|
|
377
|
+
.to raise_error(Ruact::ConfigurationError, /absent from path/)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it "rejects a v2 entry with an unknown kind (Story 9.5 — action/query only)" do
|
|
381
|
+
evil = v2_snapshot.merge(functions: [
|
|
382
|
+
{ "js_identifier" => "createPost", "kind" => "mutation",
|
|
383
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
384
|
+
])
|
|
385
|
+
expect { described_class.render(evil) }
|
|
386
|
+
.to raise_error(Ruact::ConfigurationError, /v2 entries are "action" or "query"/)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
it "rejects a v2 entry whose js_identifier is reserved" do
|
|
390
|
+
evil = v2_snapshot.merge(functions: [
|
|
391
|
+
{ "js_identifier" => "revalidate", "kind" => "action",
|
|
392
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
393
|
+
])
|
|
394
|
+
expect { described_class.render(evil) }
|
|
395
|
+
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it "rejects a v2 entry named after the v2 runtime accessor (_makeServerFunction)" do
|
|
399
|
+
evil = v2_snapshot.merge(functions: [
|
|
400
|
+
{ "js_identifier" => "_makeServerFunction", "kind" => "action",
|
|
401
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
402
|
+
])
|
|
403
|
+
expect { described_class.render(evil) }
|
|
404
|
+
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it "rejects a v2 path with an undeclared dynamic segment (bidirectional guard)" do
|
|
408
|
+
evil = v2_snapshot.merge(functions: [
|
|
409
|
+
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
410
|
+
"http_method" => "PATCH", "path" => "/posts/:id",
|
|
411
|
+
"segments" => [] }
|
|
412
|
+
])
|
|
413
|
+
expect { described_class.render(evil) }
|
|
414
|
+
.to raise_error(Ruact::ConfigurationError, /not declared in segments/)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it "rejects a v2 segment that only substring-matches a longer path token" do
|
|
418
|
+
evil = v2_snapshot.merge(functions: [
|
|
419
|
+
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
420
|
+
"http_method" => "PATCH", "path" => "/posts/:id_extra",
|
|
421
|
+
"segments" => ["id"] }
|
|
422
|
+
])
|
|
423
|
+
expect { described_class.render(evil) }
|
|
424
|
+
.to raise_error(Ruact::ConfigurationError, /absent from path/)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
describe ".render — v2 query entries (Story 9.5)", :story_9_5 do
|
|
429
|
+
def query_entry(js_id, path, accepts_params:)
|
|
430
|
+
{
|
|
431
|
+
"js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
|
|
432
|
+
"path" => path, "segments" => [], "accepts_params" => accepts_params,
|
|
433
|
+
"controller" => "CatalogQuery", "action" => js_id
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
it "emits a no-param query as _makeQuery with the () signature + useQuery re-export" do
|
|
438
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
439
|
+
query_entry("categories", "/q/categories", accepts_params: false)
|
|
440
|
+
])
|
|
441
|
+
expect(out).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
|
|
442
|
+
expect(out).to include("export const categories: () => Promise<unknown> =")
|
|
443
|
+
expect(out).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
|
|
444
|
+
expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
445
|
+
expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it "emits a param-declaring query with the (params) signature" do
|
|
449
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
450
|
+
query_entry("searchUsers", "/q/searchUsers", accepts_params: true)
|
|
451
|
+
])
|
|
452
|
+
expect(out).to include("export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =")
|
|
453
|
+
expect(out).to include('_makeQuery({ path: "/q/searchUsers", kind: "query" });')
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it "imports both accessors and re-exports useQuery for a mixed action+query snapshot" do
|
|
457
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
458
|
+
{ "js_identifier" => "createPost", "kind" => "action",
|
|
459
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] },
|
|
460
|
+
query_entry("categories", "/q/categories", accepts_params: false)
|
|
461
|
+
])
|
|
462
|
+
expect(out).to include('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";')
|
|
463
|
+
expect(out).to include("_makeServerFunction({ method: \"POST\"")
|
|
464
|
+
expect(out).to include("_makeQuery({ path: \"/q/categories\", kind: \"query\" })")
|
|
465
|
+
expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "accepts GET for a query entry (queries are GET-only)" do
|
|
469
|
+
expect do
|
|
470
|
+
described_class.render(version: 2, generated_at: "t", functions: [
|
|
471
|
+
query_entry("categories", "/q/categories", accepts_params: false)
|
|
472
|
+
])
|
|
473
|
+
end.not_to raise_error
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
it "rejects a non-GET verb on a query entry" do
|
|
477
|
+
evil = { "js_identifier" => "categories", "kind" => "query", "http_method" => "POST",
|
|
478
|
+
"path" => "/q/categories", "segments" => [] }
|
|
479
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
480
|
+
.to raise_error(Ruact::ConfigurationError, /invalid http_method/)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
it "rejects a query entry whose js_identifier is the useQuery re-export name" do
|
|
484
|
+
evil = { "js_identifier" => "useQuery", "kind" => "query", "http_method" => "GET",
|
|
485
|
+
"path" => "/q/useQuery", "segments" => [], "accepts_params" => false }
|
|
486
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
487
|
+
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it "rejects a query entry whose js_identifier is the _makeQuery import name" do
|
|
491
|
+
evil = { "js_identifier" => "_makeQuery", "kind" => "query", "http_method" => "GET",
|
|
492
|
+
"path" => "/q/_makeQuery", "segments" => [], "accepts_params" => false }
|
|
493
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
494
|
+
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
it "does NOT re-export useQuery for an action-only snapshot (byte-stable with 9.3)" do
|
|
498
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
499
|
+
{ "js_identifier" => "createPost", "kind" => "action",
|
|
500
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
501
|
+
])
|
|
502
|
+
expect(out).not_to include("useQuery")
|
|
503
|
+
expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|