ruact 0.0.11 → 0.0.12
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/CHANGELOG.md +110 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +14 -4
- data/RELEASING.md +125 -139
- data/lib/ruact/packaging.rb +68 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- metadata +6 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -12
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
|
@@ -1,397 +0,0 @@
|
|
|
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_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
|
|
12
|
-
{
|
|
13
|
-
version: 2,
|
|
14
|
-
generated_at: "2026-06-09T00:00:00Z",
|
|
15
|
-
functions: []
|
|
16
|
-
}
|
|
17
|
-
end
|
|
18
|
-
|
|
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/)
|
|
23
|
-
end
|
|
24
|
-
|
|
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/)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
it "rejects a snapshot whose version contains a line break" do
|
|
32
|
-
evil = v2_base.merge(version: "2\n// injected")
|
|
33
|
-
expect { described_class.render(evil) }
|
|
34
|
-
.to raise_error(Ruact::ConfigurationError, /version.*line break/)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
it "rejects a snapshot whose generated_at contains a line break" do
|
|
38
|
-
evil = v2_base.merge(generated_at: "2026-05-14\n// injected")
|
|
39
|
-
expect { described_class.render(evil) }
|
|
40
|
-
.to raise_error(Ruact::ConfigurationError, /generated_at.*line break/)
|
|
41
|
-
end
|
|
42
|
-
|
|
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")
|
|
45
|
-
expect { described_class.render(evil) }
|
|
46
|
-
.to raise_error(Ruact::ConfigurationError, /line break.*U\+2028/)
|
|
47
|
-
end
|
|
48
|
-
|
|
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")
|
|
51
|
-
expect { described_class.render(evil) }
|
|
52
|
-
.to raise_error(Ruact::ConfigurationError, /line break.*U\+2029/)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
it "rejects an unsupported (non-v2) snapshot version" do
|
|
56
|
-
evil = v2_base.merge(version: 1)
|
|
57
|
-
expect { described_class.render(evil) }
|
|
58
|
-
.to raise_error(Ruact::ConfigurationError, /unsupported snapshot version/)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
describe ".generate_ts! (write-if-changed wrapper)" do
|
|
63
|
-
around do |example|
|
|
64
|
-
Dir.mktmpdir do |dir|
|
|
65
|
-
@tmpdir = dir
|
|
66
|
-
example.run
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
let(:path) { File.join(@tmpdir, "server-functions.ts") }
|
|
71
|
-
|
|
72
|
-
it "writes the file on first call and returns true" do
|
|
73
|
-
expect(described_class.generate_ts!(snapshot: v2_base, output_path: path))
|
|
74
|
-
.to be(true)
|
|
75
|
-
expect(File.read(path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
|
|
76
|
-
end
|
|
77
|
-
|
|
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))
|
|
81
|
-
.to be(false)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
describe ".render — route-driven v2 snapshot (Story 9.3)", :story_9_3 do
|
|
86
|
-
let(:v2_snapshot) do
|
|
87
|
-
{
|
|
88
|
-
version: 2,
|
|
89
|
-
generated_at: "2026-06-09T00:00:00Z",
|
|
90
|
-
functions: [
|
|
91
|
-
{ "js_identifier" => "createPost", "kind" => "action",
|
|
92
|
-
"http_method" => "POST", "path" => "/posts", "segments" => [],
|
|
93
|
-
"controller" => "posts", "action" => "create" },
|
|
94
|
-
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
95
|
-
"http_method" => "PATCH", "path" => "/posts/:id", "segments" => ["id"],
|
|
96
|
-
"controller" => "posts", "action" => "update" }
|
|
97
|
-
]
|
|
98
|
-
}
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "dispatches on version 2 and emits _makeServerFunction with real path+verb" do
|
|
102
|
-
out = described_class.render(v2_snapshot)
|
|
103
|
-
expect(out).to include('import { _makeServerFunction } from "ruact/server-functions-runtime";')
|
|
104
|
-
expect(out).to include('_makeServerFunction({ method: "POST", path: "/posts", segments: [] });')
|
|
105
|
-
expect(out).to include('_makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });')
|
|
106
|
-
expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
it "keeps the Story 8.2 intersection signature on v2 actions (form action support)" do
|
|
110
|
-
out = described_class.render(v2_snapshot)
|
|
111
|
-
expect(out).to include("& ((formData: FormData) => Promise<void>)")
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
it "emits the empty-v2 module when no routes are exposed" do
|
|
115
|
-
out = described_class.render(version: 2, generated_at: "2026-06-09T00:00:00Z", functions: [])
|
|
116
|
-
expect(out).to include("void _makeServerFunction;")
|
|
117
|
-
expect(out).to include("(no server functions exposed yet")
|
|
118
|
-
expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
it "rejects a v2 entry with an invalid http_method" do
|
|
122
|
-
evil = v2_snapshot.merge(functions: [
|
|
123
|
-
{ "js_identifier" => "createPost", "kind" => "action",
|
|
124
|
-
"http_method" => "GET", "path" => "/posts", "segments" => [] }
|
|
125
|
-
])
|
|
126
|
-
expect { described_class.render(evil) }
|
|
127
|
-
.to raise_error(Ruact::ConfigurationError, /invalid http_method/)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
it "rejects a v2 entry declaring a segment absent from the path" do
|
|
131
|
-
evil = v2_snapshot.merge(functions: [
|
|
132
|
-
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
133
|
-
"http_method" => "PATCH", "path" => "/posts", "segments" => ["id"] }
|
|
134
|
-
])
|
|
135
|
-
expect { described_class.render(evil) }
|
|
136
|
-
.to raise_error(Ruact::ConfigurationError, /absent from path/)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
it "rejects a v2 entry with an unknown kind (Story 9.5 — action/query only)" do
|
|
140
|
-
evil = v2_snapshot.merge(functions: [
|
|
141
|
-
{ "js_identifier" => "createPost", "kind" => "mutation",
|
|
142
|
-
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
143
|
-
])
|
|
144
|
-
expect { described_class.render(evil) }
|
|
145
|
-
.to raise_error(Ruact::ConfigurationError, /v2 entries are "action" or "query"/)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
it "rejects a v2 entry whose js_identifier is reserved" do
|
|
149
|
-
evil = v2_snapshot.merge(functions: [
|
|
150
|
-
{ "js_identifier" => "revalidate", "kind" => "action",
|
|
151
|
-
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
152
|
-
])
|
|
153
|
-
expect { described_class.render(evil) }
|
|
154
|
-
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
it "rejects a v2 entry named after the v2 runtime accessor (_makeServerFunction)" do
|
|
158
|
-
evil = v2_snapshot.merge(functions: [
|
|
159
|
-
{ "js_identifier" => "_makeServerFunction", "kind" => "action",
|
|
160
|
-
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
161
|
-
])
|
|
162
|
-
expect { described_class.render(evil) }
|
|
163
|
-
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
it "rejects a v2 path with an undeclared dynamic segment (bidirectional guard)" do
|
|
167
|
-
evil = v2_snapshot.merge(functions: [
|
|
168
|
-
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
169
|
-
"http_method" => "PATCH", "path" => "/posts/:id",
|
|
170
|
-
"segments" => [] }
|
|
171
|
-
])
|
|
172
|
-
expect { described_class.render(evil) }
|
|
173
|
-
.to raise_error(Ruact::ConfigurationError, /not declared in segments/)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
it "rejects a v2 segment that only substring-matches a longer path token" do
|
|
177
|
-
evil = v2_snapshot.merge(functions: [
|
|
178
|
-
{ "js_identifier" => "updatePost", "kind" => "action",
|
|
179
|
-
"http_method" => "PATCH", "path" => "/posts/:id_extra",
|
|
180
|
-
"segments" => ["id"] }
|
|
181
|
-
])
|
|
182
|
-
expect { described_class.render(evil) }
|
|
183
|
-
.to raise_error(Ruact::ConfigurationError, /absent from path/)
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
describe ".render — v2 query entries (Story 9.5)", :story_9_5 do
|
|
188
|
-
def query_entry(js_id, path, accepts_params:)
|
|
189
|
-
{
|
|
190
|
-
"js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
|
|
191
|
-
"path" => path, "segments" => [], "accepts_params" => accepts_params,
|
|
192
|
-
"controller" => "CatalogQuery", "action" => js_id
|
|
193
|
-
}
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
it "emits a no-param query as _makeQuery with the () signature + useQuery re-export" do
|
|
197
|
-
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
198
|
-
query_entry("categories", "/q/categories", accepts_params: false)
|
|
199
|
-
])
|
|
200
|
-
expect(out).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
|
|
201
|
-
expect(out).to include("export const categories: () => Promise<unknown> =")
|
|
202
|
-
expect(out).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
|
|
203
|
-
expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
204
|
-
expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
it "emits a param-declaring query with the (params) signature" do
|
|
208
|
-
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
209
|
-
query_entry("searchUsers", "/q/searchUsers", accepts_params: true)
|
|
210
|
-
])
|
|
211
|
-
expect(out).to include("export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =")
|
|
212
|
-
expect(out).to include('_makeQuery({ path: "/q/searchUsers", kind: "query" });')
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
it "imports both accessors and re-exports useQuery for a mixed action+query snapshot" do
|
|
216
|
-
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
217
|
-
{ "js_identifier" => "createPost", "kind" => "action",
|
|
218
|
-
"http_method" => "POST", "path" => "/posts", "segments" => [] },
|
|
219
|
-
query_entry("categories", "/q/categories", accepts_params: false)
|
|
220
|
-
])
|
|
221
|
-
expect(out).to include('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";')
|
|
222
|
-
expect(out).to include("_makeServerFunction({ method: \"POST\"")
|
|
223
|
-
expect(out).to include("_makeQuery({ path: \"/q/categories\", kind: \"query\" })")
|
|
224
|
-
expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
it "accepts GET for a query entry (queries are GET-only)" do
|
|
228
|
-
expect do
|
|
229
|
-
described_class.render(version: 2, generated_at: "t", functions: [
|
|
230
|
-
query_entry("categories", "/q/categories", accepts_params: false)
|
|
231
|
-
])
|
|
232
|
-
end.not_to raise_error
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
it "rejects a non-GET verb on a query entry" do
|
|
236
|
-
evil = { "js_identifier" => "categories", "kind" => "query", "http_method" => "POST",
|
|
237
|
-
"path" => "/q/categories", "segments" => [] }
|
|
238
|
-
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
239
|
-
.to raise_error(Ruact::ConfigurationError, /invalid http_method/)
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
it "rejects a query entry whose js_identifier is the useQuery re-export name" do
|
|
243
|
-
evil = { "js_identifier" => "useQuery", "kind" => "query", "http_method" => "GET",
|
|
244
|
-
"path" => "/q/useQuery", "segments" => [], "accepts_params" => false }
|
|
245
|
-
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
246
|
-
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
it "rejects a query entry whose js_identifier is the _makeQuery import name" do
|
|
250
|
-
evil = { "js_identifier" => "_makeQuery", "kind" => "query", "http_method" => "GET",
|
|
251
|
-
"path" => "/q/_makeQuery", "segments" => [], "accepts_params" => false }
|
|
252
|
-
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
253
|
-
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
it "does NOT re-export useQuery for an action-only snapshot (byte-stable with 9.3)" do
|
|
257
|
-
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
258
|
-
{ "js_identifier" => "createPost", "kind" => "action",
|
|
259
|
-
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
260
|
-
])
|
|
261
|
-
expect(out).not_to include("useQuery")
|
|
262
|
-
expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
|
|
263
|
-
end
|
|
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
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
end
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
require "ruact/server_functions/error_payload"
|
|
5
|
-
|
|
6
|
-
module Ruact
|
|
7
|
-
module ServerFunctions
|
|
8
|
-
RSpec.describe ErrorPayload, :story_8_4 do
|
|
9
|
-
let(:gem_root) { "/tmp/ruact-test-gem" }
|
|
10
|
-
|
|
11
|
-
before { allow(Ruact).to receive(:gem_path).and_return(gem_root) }
|
|
12
|
-
|
|
13
|
-
# Build an exception whose `.class.name` is spoofed so the spec can
|
|
14
|
-
# exercise the RecordInvalid path without requiring ActiveRecord.
|
|
15
|
-
def build_record_invalid(full_messages: ["Title can't be blank"])
|
|
16
|
-
record = Object.new
|
|
17
|
-
errors = Object.new
|
|
18
|
-
record.define_singleton_method(:errors) { errors }
|
|
19
|
-
errors.define_singleton_method(:full_messages) { full_messages }
|
|
20
|
-
|
|
21
|
-
klass = Class.new(StandardError) do
|
|
22
|
-
attr_reader :record
|
|
23
|
-
define_singleton_method(:name) { "ActiveRecord::RecordInvalid" }
|
|
24
|
-
def initialize(record, message)
|
|
25
|
-
super(message)
|
|
26
|
-
@record = record
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
err = klass.new(record, "Validation failed: Title can't be blank")
|
|
30
|
-
err.set_backtrace([
|
|
31
|
-
"/Users/dev/host/app/controllers/posts_controller.rb:12:in `create'",
|
|
32
|
-
"#{gem_root}/lib/ruact/server_functions/endpoint_controller.rb:111:in `dispatch'"
|
|
33
|
-
])
|
|
34
|
-
err
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
describe ".build in :development mode" do
|
|
38
|
-
let(:error) { build_record_invalid }
|
|
39
|
-
|
|
40
|
-
subject(:payload) do
|
|
41
|
-
described_class.build(action_name: :create_post, error: error, mode: :development)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it "carries the discriminator" do
|
|
45
|
-
expect(payload["_ruact_server_action_error"]).to be(true)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
it "exposes the action name as a string (symbol input is coerced)" do
|
|
49
|
-
expect(payload["action_name"]).to eq("create_post")
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
it "exposes the error class as a string" do
|
|
53
|
-
expect(payload["error_class"]).to eq("ActiveRecord::RecordInvalid")
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
it "exposes the error message" do
|
|
57
|
-
expect(payload["message"]).to eq("Validation failed: Title can't be blank")
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
it "splits the backtrace into app_frames and gem_frames" do
|
|
61
|
-
expect(payload["app_frames"]).to contain_exactly(
|
|
62
|
-
a_string_including("posts_controller.rb:12")
|
|
63
|
-
)
|
|
64
|
-
expect(payload["gem_frames"]).to contain_exactly(
|
|
65
|
-
a_string_including("endpoint_controller.rb:111")
|
|
66
|
-
)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
it "extracts validation_errors from the record" do
|
|
70
|
-
expect(payload["validation_errors"]).to eq(["Title can't be blank"])
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
it "extracts the contextual suggestion" do
|
|
74
|
-
expect(payload["suggestion"]).to eq("Validation failed — check the model's `validates` rules")
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
describe ".build in :production mode" do
|
|
79
|
-
let(:error) { build_record_invalid }
|
|
80
|
-
|
|
81
|
-
subject(:payload) do
|
|
82
|
-
described_class.build(action_name: :create_post, error: error, mode: :production)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
it "carries the four baseline keys" do
|
|
86
|
-
expect(payload.keys).to contain_exactly(
|
|
87
|
-
"_ruact_server_action_error",
|
|
88
|
-
"action_name",
|
|
89
|
-
"error_class",
|
|
90
|
-
"message"
|
|
91
|
-
)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
it "shares the four baseline values with the dev-mode payload" do
|
|
95
|
-
dev = described_class.build(action_name: :create_post, error: error, mode: :development)
|
|
96
|
-
payload.each_key do |key|
|
|
97
|
-
expect(payload[key]).to eq(dev[key])
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "ABSENT (not null): app_frames, gem_frames, suggestion, validation_errors" do
|
|
102
|
-
expect(payload).not_to have_key("app_frames")
|
|
103
|
-
expect(payload).not_to have_key("gem_frames")
|
|
104
|
-
expect(payload).not_to have_key("suggestion")
|
|
105
|
-
expect(payload).not_to have_key("validation_errors")
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
describe "validation_errors edge cases (dev mode)" do
|
|
110
|
-
it "is [] when RecordInvalid was constructed without a record" do
|
|
111
|
-
klass = Class.new(StandardError) do
|
|
112
|
-
attr_reader :record
|
|
113
|
-
define_singleton_method(:name) { "ActiveRecord::RecordInvalid" }
|
|
114
|
-
def initialize
|
|
115
|
-
super("boom")
|
|
116
|
-
@record = nil
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
payload = described_class.build(action_name: :x, error: klass.new, mode: :development)
|
|
120
|
-
expect(payload["validation_errors"]).to eq([])
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
it "is ABSENT (no key) when the error class is not RecordInvalid" do
|
|
124
|
-
payload = described_class.build(
|
|
125
|
-
action_name: :x,
|
|
126
|
-
error: RuntimeError.new("boom"),
|
|
127
|
-
mode: :development
|
|
128
|
-
)
|
|
129
|
-
expect(payload).not_to have_key("validation_errors")
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
describe "suggestion is null for unknown error classes (dev mode)" do
|
|
134
|
-
it "produces suggestion: nil for RuntimeError" do
|
|
135
|
-
payload = described_class.build(
|
|
136
|
-
action_name: :x,
|
|
137
|
-
error: RuntimeError.new("boom"),
|
|
138
|
-
mode: :development
|
|
139
|
-
)
|
|
140
|
-
expect(payload["suggestion"]).to be_nil
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
describe "Pitfall #5 — frozen-string error message safety" do
|
|
145
|
-
it "does not raise FrozenError when the error message is frozen" do
|
|
146
|
-
err = StandardError.new("frozen msg".freeze)
|
|
147
|
-
expect do
|
|
148
|
-
described_class.build(action_name: :x, error: err, mode: :development)
|
|
149
|
-
end.not_to raise_error
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
it "stores a mutable dup of the message in the payload" do
|
|
153
|
-
err = StandardError.new("frozen msg".freeze)
|
|
154
|
-
payload = described_class.build(action_name: :x, error: err, mode: :development)
|
|
155
|
-
expect(payload["message"]).to eq("frozen msg")
|
|
156
|
-
expect(payload["message"]).not_to be_frozen
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
describe "Story 8.5 — UploadTooLargeError gets a dev-only upload_limit block", :story_8_5 do
|
|
161
|
-
let(:error) { Ruact::UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760) }
|
|
162
|
-
|
|
163
|
-
it "exposes received_bytes and limit_bytes under upload_limit (dev mode)" do
|
|
164
|
-
payload = described_class.build(action_name: :upload_post, error: error, mode: :development)
|
|
165
|
-
expect(payload["upload_limit"]).to eq(
|
|
166
|
-
"received_bytes" => 11_534_336,
|
|
167
|
-
"limit_bytes" => 10_485_760
|
|
168
|
-
)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
it "carries error_class as Ruact::UploadTooLargeError" do
|
|
172
|
-
payload = described_class.build(action_name: :upload_post, error: error, mode: :development)
|
|
173
|
-
expect(payload["error_class"]).to eq("Ruact::UploadTooLargeError")
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
it "extracts the upload-too-large suggestion from ErrorSuggestion" do
|
|
177
|
-
payload = described_class.build(action_name: :upload_post, error: error, mode: :development)
|
|
178
|
-
expect(payload["suggestion"]).to include("Increase Ruact.config.max_upload_bytes")
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
it "does NOT include upload_limit in :production mode (four baseline keys only)" do
|
|
182
|
-
payload = described_class.build(action_name: :upload_post, error: error, mode: :production)
|
|
183
|
-
expect(payload).not_to have_key("upload_limit")
|
|
184
|
-
expect(payload.keys).to contain_exactly(
|
|
185
|
-
"_ruact_server_action_error",
|
|
186
|
-
"action_name",
|
|
187
|
-
"error_class",
|
|
188
|
-
"message"
|
|
189
|
-
)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
it "is ABSENT (no key) when the error class is not UploadTooLargeError" do
|
|
193
|
-
payload = described_class.build(
|
|
194
|
-
action_name: :x,
|
|
195
|
-
error: RuntimeError.new("boom"),
|
|
196
|
-
mode: :development
|
|
197
|
-
)
|
|
198
|
-
expect(payload).not_to have_key("upload_limit")
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
describe "backtrace edge cases (dev mode)" do
|
|
203
|
-
it "is { app_frames: [], gem_frames: [] } when backtrace is nil" do
|
|
204
|
-
err = StandardError.new("boom") # never raised => backtrace is nil
|
|
205
|
-
payload = described_class.build(action_name: :x, error: err, mode: :development)
|
|
206
|
-
expect(payload["app_frames"]).to eq([])
|
|
207
|
-
expect(payload["gem_frames"]).to eq([])
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
it "caps each bucket at MAX_FRAMES_PER_BUCKET frames" do
|
|
211
|
-
app_frames = Array.new(40) { |i| "/Users/dev/host/app/file_#{i}.rb:#{i}" }
|
|
212
|
-
gem_frames = Array.new(40) { |i| "#{gem_root}/lib/ruact/file_#{i}.rb:#{i}" }
|
|
213
|
-
err = StandardError.new("boom")
|
|
214
|
-
err.set_backtrace(app_frames + gem_frames)
|
|
215
|
-
payload = described_class.build(action_name: :x, error: err, mode: :development)
|
|
216
|
-
expect(payload["app_frames"].size).to eq(ErrorPayload::MAX_FRAMES_PER_BUCKET)
|
|
217
|
-
expect(payload["gem_frames"].size).to eq(ErrorPayload::MAX_FRAMES_PER_BUCKET)
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
end
|