ruact 0.0.10 → 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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +16 -6
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -1
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. 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