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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +88 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1779 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +100 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,827 @@
1
+ // Story 8.1 — vitest suite for the real server-functions runtime.
2
+ //
3
+ // Covers AC10 of Story 8.1: argument-shape branching (JSON vs FormData),
4
+ // CSRF meta-tag injection, success vs. failure response handling, and
5
+ // error wrapping. Uses `vi.fn()` to stub `fetch` — no real network.
6
+
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
8
+ import {
9
+ _makeRef,
10
+ _makeServerFunction,
11
+ __RUNTIME_VERSION__,
12
+ __internals,
13
+ RuactActionError,
14
+ configureRuactRuntime,
15
+ revalidate,
16
+ } from "./index.js";
17
+
18
+ let originalFetch;
19
+ let originalDocument;
20
+
21
+ beforeEach(() => {
22
+ originalFetch = globalThis.fetch;
23
+ originalDocument = globalThis.document;
24
+ });
25
+
26
+ afterEach(() => {
27
+ globalThis.fetch = originalFetch;
28
+ globalThis.document = originalDocument;
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ function mockFetchOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
33
+ // Re-run-2 (2026-05-14) — parseResponse now reads `text()` first, then
34
+ // JSON.parses if Content-Type says JSON. Default the text mock to the
35
+ // JSON-stringified body so tests still see the structured value.
36
+ const response = {
37
+ ok: true,
38
+ status,
39
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
40
+ json: vi.fn().mockResolvedValue(jsonBody),
41
+ text: vi.fn().mockResolvedValue(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
42
+ };
43
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
44
+ return response;
45
+ }
46
+
47
+ function mockFetchError(status, bodyText) {
48
+ const response = {
49
+ ok: false,
50
+ status,
51
+ headers: { get: () => "text/plain" },
52
+ text: vi.fn().mockResolvedValue(bodyText),
53
+ json: vi.fn(),
54
+ };
55
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
56
+ return response;
57
+ }
58
+
59
+ function mockMetaTag(token) {
60
+ globalThis.document = {
61
+ querySelector: vi.fn().mockImplementation((selector) => {
62
+ if (selector === 'meta[name="csrf-token"]' && token !== null) {
63
+ return { getAttribute: () => token };
64
+ }
65
+ return null;
66
+ }),
67
+ };
68
+ }
69
+
70
+ describe("Story 8.1 — _makeRef", () => {
71
+ it("exports the runtime-version sentinel (placeholder __PLACEHOLDER__ is gone)", () => {
72
+ expect(__RUNTIME_VERSION__).toBe(1);
73
+ });
74
+
75
+ it("returns a callable accessor", () => {
76
+ const ref = _makeRef("create_post");
77
+ expect(typeof ref).toBe("function");
78
+ });
79
+ });
80
+
81
+ describe("Story 8.1 — JSON body branch", () => {
82
+ it("POSTs JSON.stringify(args) with Content-Type: application/json", async () => {
83
+ mockFetchOk({ ok: true });
84
+ mockMetaTag(null);
85
+
86
+ await _makeRef("create_post")({ title: "Hi" });
87
+
88
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
89
+ const [url, init] = globalThis.fetch.mock.calls[0];
90
+ expect(url).toBe("/__ruact/fn/create_post");
91
+ expect(init.method).toBe("POST");
92
+ expect(init.credentials).toBe("same-origin");
93
+ expect(init.headers["Content-Type"]).toBe("application/json");
94
+ expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
95
+ });
96
+
97
+ it("treats undefined args as an empty JSON object {}", async () => {
98
+ mockFetchOk({});
99
+ mockMetaTag(null);
100
+ await _makeRef("categories")();
101
+
102
+ const [, init] = globalThis.fetch.mock.calls[0];
103
+ expect(init.body).toBe("{}");
104
+ });
105
+
106
+ it("treats null args as an empty JSON object {}", async () => {
107
+ mockFetchOk({});
108
+ mockMetaTag(null);
109
+ await _makeRef("categories")(null);
110
+
111
+ const [, init] = globalThis.fetch.mock.calls[0];
112
+ expect(init.body).toBe("{}");
113
+ });
114
+
115
+ it("resolves with parsed JSON for application/json responses", async () => {
116
+ mockFetchOk({ id: 7 });
117
+ mockMetaTag(null);
118
+ const result = await _makeRef("create_post")({ title: "x" });
119
+ expect(result).toEqual({ id: 7 });
120
+ });
121
+
122
+ it("attaches Accept: application/json header (re-run-2 #8 — host respond_to branching)", async () => {
123
+ mockFetchOk({});
124
+ mockMetaTag(null);
125
+ await _makeRef("create_post")({});
126
+ const [, init] = globalThis.fetch.mock.calls[0];
127
+ expect(init.headers.Accept).toBe("application/json");
128
+ });
129
+
130
+ it("resolves with raw text for non-JSON responses", async () => {
131
+ const r = {
132
+ ok: true,
133
+ status: 200,
134
+ headers: { get: () => "text/plain" },
135
+ text: vi.fn().mockResolvedValue("hello"),
136
+ json: vi.fn(),
137
+ };
138
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
139
+ mockMetaTag(null);
140
+
141
+ const result = await _makeRef("ping")({});
142
+ expect(result).toBe("hello");
143
+ expect(r.text).toHaveBeenCalled();
144
+ expect(r.json).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("resolves with null for non-204 empty-body responses (e.g., `head :ok`, 205) " +
148
+ "(re-run-2 #7 — text-first parse)", async () => {
149
+ const r = {
150
+ ok: true,
151
+ status: 205,
152
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? "application/json" : null) },
153
+ text: vi.fn().mockResolvedValue(""),
154
+ json: vi.fn().mockRejectedValue(new SyntaxError("Unexpected end of JSON input")),
155
+ };
156
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
157
+ mockMetaTag(null);
158
+
159
+ const result = await _makeRef("noop")({});
160
+ expect(result).toBeNull();
161
+ expect(r.json).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("resolves with null for 204 No Content responses", async () => {
165
+ const r = {
166
+ ok: true,
167
+ status: 204,
168
+ headers: { get: () => null },
169
+ text: vi.fn().mockResolvedValue(""),
170
+ json: vi.fn(),
171
+ };
172
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
173
+ mockMetaTag(null);
174
+
175
+ const result = await _makeRef("noop")({});
176
+ expect(result).toBeNull();
177
+ });
178
+
179
+ it("resolves with null for 204 No Content even when Content-Type says application/json " +
180
+ "(review-batch 4 + re-run-2 — empty body → null regardless of Content-Type)", async () => {
181
+ const r = {
182
+ ok: true,
183
+ status: 204,
184
+ // Rails sends `head :no_content` with Content-Type: application/json
185
+ // when the controller is in a JSON-context. The 204 still has no body.
186
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? "application/json" : null) },
187
+ text: vi.fn().mockResolvedValue(""),
188
+ json: vi.fn().mockRejectedValue(new SyntaxError("Unexpected end of JSON input")),
189
+ };
190
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
191
+ mockMetaTag(null);
192
+
193
+ const result = await _makeRef("noop")({});
194
+ expect(result).toBeNull();
195
+ expect(r.json).not.toHaveBeenCalled();
196
+ });
197
+ });
198
+
199
+ describe("Story 8.1 — FormData branch", () => {
200
+ it("POSTs the FormData as-is with NO manual Content-Type header (browser sets boundary)", async () => {
201
+ mockFetchOk({ ok: true });
202
+ mockMetaTag(null);
203
+
204
+ const fd = new FormData();
205
+ fd.append("title", "From form");
206
+
207
+ await _makeRef("create_post")(fd);
208
+
209
+ const [, init] = globalThis.fetch.mock.calls[0];
210
+ expect(init.body).toBe(fd);
211
+ expect(init.headers["Content-Type"]).toBeUndefined();
212
+ });
213
+ });
214
+
215
+ describe("Story 8.1 — CSRF header injection", () => {
216
+ it("attaches X-CSRF-Token header when <meta name=\"csrf-token\"> is present", async () => {
217
+ mockFetchOk({ ok: true });
218
+ mockMetaTag("token-abc123");
219
+
220
+ await _makeRef("create_post")({ title: "x" });
221
+
222
+ const [, init] = globalThis.fetch.mock.calls[0];
223
+ expect(init.headers["X-CSRF-Token"]).toBe("token-abc123");
224
+ });
225
+
226
+ it("omits X-CSRF-Token header when the meta tag is absent (API mode)", async () => {
227
+ mockFetchOk({ ok: true });
228
+ mockMetaTag(null);
229
+
230
+ await _makeRef("create_post")({ title: "x" });
231
+
232
+ const [, init] = globalThis.fetch.mock.calls[0];
233
+ expect(init.headers["X-CSRF-Token"]).toBeUndefined();
234
+ });
235
+
236
+ it("works without document defined (Node / SSR contexts)", async () => {
237
+ mockFetchOk({ ok: true });
238
+ globalThis.document = undefined;
239
+
240
+ await expect(_makeRef("create_post")({ title: "x" })).resolves.toEqual({ ok: true });
241
+ });
242
+ });
243
+
244
+ describe("Story 8.1 — error responses", () => {
245
+ it("rejects with a structured Error on 4xx responses", async () => {
246
+ mockFetchError(422, "validation failed");
247
+ mockMetaTag(null);
248
+
249
+ await expect(_makeRef("create_post")({ title: "" })).rejects.toThrow(
250
+ /ruact action :create_post failed: 422 validation failed/,
251
+ );
252
+ });
253
+
254
+ it("rejects with a structured Error on 5xx responses", async () => {
255
+ mockFetchError(500, "boom");
256
+ mockMetaTag(null);
257
+
258
+ await expect(_makeRef("create_post")({})).rejects.toThrow(
259
+ /ruact action :create_post failed: 500 boom/,
260
+ );
261
+ });
262
+
263
+ it("rejects with a structured Error on fetch network failure", async () => {
264
+ globalThis.fetch = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
265
+ mockMetaTag(null);
266
+
267
+ await expect(_makeRef("create_post")({})).rejects.toThrow(
268
+ /ruact action :create_post request failed: Failed to fetch/,
269
+ );
270
+ });
271
+ });
272
+
273
+ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)", () => {
274
+ it("rejects with a RuactActionError exposing status and parsed JSON body on 422", async () => {
275
+ const r = {
276
+ ok: false,
277
+ status: 422,
278
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? "application/json" : null) },
279
+ text: vi.fn().mockResolvedValue(JSON.stringify({ errors: { title: ["can't be blank"] } })),
280
+ json: vi.fn(),
281
+ };
282
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
283
+ mockMetaTag(null);
284
+
285
+ let captured = null;
286
+ try {
287
+ await _makeRef("create_post")({ title: "" });
288
+ } catch (err) {
289
+ captured = err;
290
+ }
291
+ expect(captured).toBeInstanceOf(RuactActionError);
292
+ expect(captured.status).toBe(422);
293
+ expect(captured.actionName).toBe("create_post");
294
+ expect(captured.body).toEqual({ errors: { title: ["can't be blank"] } });
295
+ });
296
+
297
+ it("RuactActionError.body holds raw text when the response is not JSON", async () => {
298
+ mockFetchError(500, "boom");
299
+ mockMetaTag(null);
300
+
301
+ let captured = null;
302
+ try {
303
+ await _makeRef("create_post")({});
304
+ } catch (err) {
305
+ captured = err;
306
+ }
307
+ expect(captured).toBeInstanceOf(RuactActionError);
308
+ expect(captured.status).toBe(500);
309
+ expect(captured.body).toBe("boom");
310
+ });
311
+ });
312
+
313
+ describe("Story 8.1 — Re-run-4 — +json structured-syntax-suffix media types (#7)", () => {
314
+ it("parses application/problem+json as JSON (RFC 6838 §4.2.8)", async () => {
315
+ const r = {
316
+ ok: true,
317
+ status: 200,
318
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? "application/problem+json" : null) },
319
+ text: vi.fn().mockResolvedValue(JSON.stringify({ type: "about:blank", title: "ok" })),
320
+ json: vi.fn(),
321
+ };
322
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
323
+ mockMetaTag(null);
324
+
325
+ const result = await _makeRef("noop")({});
326
+ expect(result).toEqual({ type: "about:blank", title: "ok" });
327
+ });
328
+
329
+ it("parses application/vnd.api+json as JSON", async () => {
330
+ const r = {
331
+ ok: true,
332
+ status: 200,
333
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? "application/vnd.api+json" : null) },
334
+ text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "1" } })),
335
+ json: vi.fn(),
336
+ };
337
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
338
+ mockMetaTag(null);
339
+
340
+ const result = await _makeRef("noop")({});
341
+ expect(result).toEqual({ data: { id: "1" } });
342
+ });
343
+ });
344
+
345
+ describe("Story 8.1 — Re-run-3 — URL encoding of name (#6)", () => {
346
+ it("encodeURIComponent's the name so a stray '/' cannot rewrite the path", async () => {
347
+ mockFetchOk({ ok: true });
348
+ mockMetaTag(null);
349
+
350
+ await _makeRef("../foo?x=1")({});
351
+
352
+ const [url] = globalThis.fetch.mock.calls[0];
353
+ expect(url).toBe("/__ruact/fn/..%2Ffoo%3Fx%3D1");
354
+ });
355
+ });
356
+
357
+ describe("Story 8.1 — Re-run-3 — Content-Type matching is case-insensitive (#5)", () => {
358
+ it("parses JSON when Content-Type is `Application/JSON` (RFC 9110 — case-insensitive media type)", async () => {
359
+ const r = {
360
+ ok: true,
361
+ status: 200,
362
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? "Application/JSON; charset=utf-8" : null) },
363
+ text: vi.fn().mockResolvedValue('{"id":42}'),
364
+ json: vi.fn(),
365
+ };
366
+ globalThis.fetch = vi.fn().mockResolvedValue(r);
367
+ mockMetaTag(null);
368
+
369
+ const result = await _makeRef("noop")({});
370
+ expect(result).toEqual({ id: 42 });
371
+ });
372
+ });
373
+
374
+ describe("Story 8.1 — Re-run-5 — fetch redirect: 'error' (#5)", () => {
375
+ it("sets redirect: 'error' on the fetch init so auth `redirect_to` failures surface as errors", async () => {
376
+ mockFetchOk({ ok: true });
377
+ mockMetaTag(null);
378
+
379
+ await _makeRef("create_post")({});
380
+
381
+ const [, init] = globalThis.fetch.mock.calls[0];
382
+ expect(init.redirect).toBe("error");
383
+ });
384
+ });
385
+
386
+ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
387
+ afterEach(() => {
388
+ configureRuactRuntime({ defaultHeaders: null });
389
+ });
390
+
391
+ it("merges defaultHeaders object into every fetch init", async () => {
392
+ mockFetchOk({ ok: true });
393
+ mockMetaTag(null);
394
+ configureRuactRuntime({ defaultHeaders: { Authorization: "Bearer abc" } });
395
+
396
+ await _makeRef("create_post")({});
397
+
398
+ const [, init] = globalThis.fetch.mock.calls[0];
399
+ expect(init.headers.Authorization).toBe("Bearer abc");
400
+ });
401
+
402
+ it("calls a defaultHeaders function on every request so tokens can refresh", async () => {
403
+ mockFetchOk({ ok: true });
404
+ mockMetaTag(null);
405
+ let calls = 0;
406
+ configureRuactRuntime({
407
+ defaultHeaders: () => {
408
+ calls += 1;
409
+ return { Authorization: `Bearer t${calls}` };
410
+ },
411
+ });
412
+
413
+ await _makeRef("create_post")({});
414
+ await _makeRef("create_post")({});
415
+
416
+ expect(globalThis.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer t1");
417
+ expect(globalThis.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer t2");
418
+ });
419
+
420
+ it("does NOT let defaultHeaders override the gem's CSRF / Accept / Content-Type", async () => {
421
+ mockFetchOk({ ok: true });
422
+ mockMetaTag("real-csrf");
423
+ configureRuactRuntime({
424
+ defaultHeaders: {
425
+ "X-CSRF-Token": "tampered",
426
+ Accept: "text/html",
427
+ "Content-Type": "application/xml",
428
+ },
429
+ });
430
+
431
+ await _makeRef("create_post")({});
432
+
433
+ const [, init] = globalThis.fetch.mock.calls[0];
434
+ expect(init.headers["X-CSRF-Token"]).toBe("real-csrf");
435
+ expect(init.headers.Accept).toBe("application/json");
436
+ expect(init.headers["Content-Type"]).toBe("application/json");
437
+ });
438
+
439
+ it("rejects non-object/non-function/non-null defaultHeaders", () => {
440
+ expect(() => configureRuactRuntime({ defaultHeaders: "Bearer abc" })).toThrow(
441
+ /must be a plain object or a \(\) => object function/,
442
+ );
443
+ });
444
+
445
+ it("strips reserved headers from defaultHeaders case-insensitively (re-run-6 #3)", async () => {
446
+ // HTTP header names are case-insensitive (RFC 9110 §5.1). A host passing
447
+ // `{ accept: "text/html" }` or `{ "content-type": "application/xml" }`
448
+ // must NOT survive into the request: the gem owns these keys regardless
449
+ // of casing.
450
+ mockFetchOk({ ok: true });
451
+ mockMetaTag("real-csrf");
452
+ configureRuactRuntime({
453
+ defaultHeaders: {
454
+ accept: "text/html",
455
+ "content-type": "application/xml",
456
+ "x-csrf-token": "tampered",
457
+ "X-Custom-Header": "kept",
458
+ },
459
+ });
460
+
461
+ await _makeRef("create_post")({});
462
+
463
+ const [, init] = globalThis.fetch.mock.calls[0];
464
+ expect(init.headers.Accept).toBe("application/json");
465
+ expect(init.headers["Content-Type"]).toBe("application/json");
466
+ expect(init.headers["X-CSRF-Token"]).toBe("real-csrf");
467
+ expect(init.headers["X-Custom-Header"]).toBe("kept");
468
+ // None of the lowercased reserved keys should leak through.
469
+ expect(init.headers.accept).toBeUndefined();
470
+ expect(init.headers["content-type"]).toBeUndefined();
471
+ expect(init.headers["x-csrf-token"]).toBeUndefined();
472
+ });
473
+
474
+ it("does NOT let defaultHeaders set Content-Type for FormData requests (re-run-6 #3)", async () => {
475
+ // FormData branch relies on the browser to set
476
+ // `Content-Type: multipart/form-data; boundary=…`. A surviving
477
+ // `Content-Type` from defaultHeaders would override that and break
478
+ // multipart parsing on the server.
479
+ mockFetchOk({ ok: true });
480
+ configureRuactRuntime({
481
+ defaultHeaders: { "content-type": "application/xml" },
482
+ });
483
+
484
+ const fd = new FormData();
485
+ fd.append("title", "Hello");
486
+ await _makeRef("upload")(fd);
487
+
488
+ const [, init] = globalThis.fetch.mock.calls[0];
489
+ expect(init.headers["Content-Type"]).toBeUndefined();
490
+ expect(init.headers["content-type"]).toBeUndefined();
491
+ expect(init.body).toBe(fd);
492
+ });
493
+ });
494
+
495
+ describe("Story 8.1 — __internals (test-only surface)", () => {
496
+ it("exposes buildFetchInit, resolveCsrfToken, parseResponse for granular asserts", () => {
497
+ expect(typeof __internals.buildFetchInit).toBe("function");
498
+ expect(typeof __internals.resolveCsrfToken).toBe("function");
499
+ expect(typeof __internals.parseResponse).toBe("function");
500
+ });
501
+
502
+ it("Story 8.2 — exposes pickWirePayload (the two-arg shape-detection helper)", () => {
503
+ expect(typeof __internals.pickWirePayload).toBe("function");
504
+ });
505
+ });
506
+
507
+ // =============================================================================
508
+ // Story 8.2 — useActionState two-arg invocation
509
+ // =============================================================================
510
+
511
+ describe("Story 8.2 — _makeRef call-shape detection", () => {
512
+ it("fn() — zero args sends an empty JSON body (parity with Story 8.1 fn() shape)", async () => {
513
+ mockFetchOk({});
514
+ mockMetaTag(null);
515
+
516
+ await _makeRef("noop")();
517
+
518
+ const [, init] = globalThis.fetch.mock.calls[0];
519
+ expect(init.body).toBe("{}");
520
+ expect(init.headers["Content-Type"]).toBe("application/json");
521
+ });
522
+
523
+ it("fn(obj) — single plain-object arg sends JSON body (Story 8.1 baseline)", async () => {
524
+ mockFetchOk({});
525
+ mockMetaTag(null);
526
+
527
+ await _makeRef("create_post")({ title: "Hi" });
528
+
529
+ const [, init] = globalThis.fetch.mock.calls[0];
530
+ expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
531
+ });
532
+
533
+ it("fn(formData) — single FormData arg sends multipart (Story 8.1 baseline)", async () => {
534
+ mockFetchOk({});
535
+ mockMetaTag(null);
536
+
537
+ const fd = new FormData();
538
+ fd.append("title", "Hi");
539
+ await _makeRef("create_post")(fd);
540
+
541
+ const [, init] = globalThis.fetch.mock.calls[0];
542
+ expect(init.body).toBe(fd);
543
+ expect(init.headers["Content-Type"]).toBeUndefined();
544
+ });
545
+
546
+ it("fn(prevState, formData) — useActionState shape; FormData wins, prevState " +
547
+ "is silently discarded (the wire request is IDENTICAL to fn(formData))", async () => {
548
+ mockFetchOk({});
549
+ mockMetaTag(null);
550
+
551
+ const fd = new FormData();
552
+ fd.append("title", "From form");
553
+ await _makeRef("create_post")({ message: "previous state" }, fd);
554
+
555
+ const [, init] = globalThis.fetch.mock.calls[0];
556
+ expect(init.body).toBe(fd);
557
+ expect(init.headers["Content-Type"]).toBeUndefined();
558
+ });
559
+
560
+ it("fn(prevState, obj) — both non-FormData (defensive case); the SECOND arg " +
561
+ "is the payload (useActionState ordering), the first is discarded", async () => {
562
+ mockFetchOk({});
563
+ mockMetaTag(null);
564
+
565
+ await _makeRef("create_post")({ message: "previous state" }, { title: "Hi" });
566
+
567
+ const [, init] = globalThis.fetch.mock.calls[0];
568
+ expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
569
+ expect(init.headers["Content-Type"]).toBe("application/json");
570
+ });
571
+
572
+ it("fn(formData, obj) — FormData in slot 0 still wins (defensive; not the " +
573
+ "useActionState shape but exercised for completeness)", async () => {
574
+ mockFetchOk({});
575
+ mockMetaTag(null);
576
+
577
+ const fd = new FormData();
578
+ fd.append("title", "FD");
579
+ await _makeRef("create_post")(fd, { title: "obj" });
580
+
581
+ const [, init] = globalThis.fetch.mock.calls[0];
582
+ expect(init.body).toBe(fd);
583
+ });
584
+
585
+ it("fn(a, b, c) — three or more args throws TypeError with a descriptive message", () => {
586
+ expect(() => _makeRef("create_post")(1, 2, 3)).toThrow(TypeError);
587
+ expect(() => _makeRef("create_post")(1, 2, 3)).toThrow(
588
+ /ruact action :create_post called with 3 arguments — expected 0, 1, or 2/,
589
+ );
590
+ });
591
+
592
+ it("prev-state shape is never serialized to the wire — even when it contains " +
593
+ "non-serializable values (Pitfall #4 — Date / Map / circular refs)", async () => {
594
+ mockFetchOk({});
595
+ mockMetaTag(null);
596
+
597
+ const circular = {};
598
+ circular.self = circular;
599
+ const fd = new FormData();
600
+ fd.append("title", "Hi");
601
+ // Pre-Story-8.2 this would have thrown on JSON.stringify(circular). The
602
+ // wire path never sees prevState, so circular references are harmless.
603
+ await expect(
604
+ _makeRef("create_post")(circular, fd),
605
+ ).resolves.not.toThrow();
606
+
607
+ const [, init] = globalThis.fetch.mock.calls[0];
608
+ expect(init.body).toBe(fd);
609
+ });
610
+ });
611
+
612
+ // =============================================================================
613
+ // Story 8.2 — revalidate() runtime helper
614
+ // =============================================================================
615
+
616
+ describe("Story 8.2 — revalidate()", () => {
617
+ let originalRevalidate;
618
+ let originalLocation;
619
+
620
+ beforeEach(() => {
621
+ originalRevalidate = globalThis.__ruact_revalidate;
622
+ originalLocation = globalThis.location;
623
+ });
624
+
625
+ afterEach(() => {
626
+ if (originalRevalidate === undefined) delete globalThis.__ruact_revalidate;
627
+ else globalThis.__ruact_revalidate = originalRevalidate;
628
+ if (originalLocation === undefined) {
629
+ // jsdom / browser env — leave the real `location` alone
630
+ } else {
631
+ globalThis.location = originalLocation;
632
+ }
633
+ });
634
+
635
+ it("invokes the published handle with location.pathname + location.search when " +
636
+ "no path is provided", async () => {
637
+ const spy = vi.fn().mockResolvedValue(undefined);
638
+ globalThis.__ruact_revalidate = spy;
639
+ globalThis.location = { pathname: "/posts", search: "?page=2" };
640
+
641
+ await revalidate();
642
+ expect(spy).toHaveBeenCalledWith("/posts?page=2");
643
+ });
644
+
645
+ it("invokes the published handle with the explicit path when one is supplied", async () => {
646
+ const spy = vi.fn().mockResolvedValue(undefined);
647
+ globalThis.__ruact_revalidate = spy;
648
+
649
+ await revalidate("/posts");
650
+ expect(spy).toHaveBeenCalledWith("/posts");
651
+ });
652
+
653
+ it("invokes the handle with the path even when it does not match location (the " +
654
+ "router decides whether to push history)", async () => {
655
+ const spy = vi.fn().mockResolvedValue(undefined);
656
+ globalThis.__ruact_revalidate = spy;
657
+
658
+ await revalidate("/elsewhere?x=1");
659
+ expect(spy).toHaveBeenCalledWith("/elsewhere?x=1");
660
+ });
661
+
662
+ it("throws a descriptive error when no router is installed (the published handle " +
663
+ "is missing) — fails loudly instead of silently no-op'ing", async () => {
664
+ if ("__ruact_revalidate" in globalThis) delete globalThis.__ruact_revalidate;
665
+
666
+ await expect(revalidate()).rejects.toThrow(
667
+ /ruact: revalidate\(\) called but no router is installed/,
668
+ );
669
+ });
670
+
671
+ it("propagates the resolved value (whatever the router returns) so callers can " +
672
+ "`await revalidate()` and then continue", async () => {
673
+ const spy = vi.fn().mockResolvedValue("ok");
674
+ globalThis.__ruact_revalidate = spy;
675
+ globalThis.location = { pathname: "/", search: "" };
676
+
677
+ const result = await revalidate();
678
+ expect(result).toBe("ok");
679
+ });
680
+
681
+ it("propagates rejections from the router so callers can catch network failures", async () => {
682
+ const error = new Error("Failed to fetch");
683
+ const spy = vi.fn().mockRejectedValue(error);
684
+ globalThis.__ruact_revalidate = spy;
685
+ globalThis.location = { pathname: "/", search: "" };
686
+
687
+ await expect(revalidate()).rejects.toThrow("Failed to fetch");
688
+ });
689
+ });
690
+
691
+ describe("Story 9.3 — _makeServerFunction (route-driven, real path+verb)", () => {
692
+ let originalNavigate;
693
+ let originalWindow;
694
+
695
+ beforeEach(() => {
696
+ originalNavigate = globalThis.__ruact_navigate;
697
+ originalWindow = globalThis.window;
698
+ });
699
+
700
+ afterEach(() => {
701
+ globalThis.__ruact_navigate = originalNavigate;
702
+ globalThis.window = originalWindow;
703
+ });
704
+
705
+ it("targets the real path + verb (POST /posts) — not the v1 synthetic endpoint", async () => {
706
+ mockFetchOk({ post: { id: 1 } });
707
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
708
+
709
+ const result = await createPost({ title: "Hi" });
710
+
711
+ const [url, init] = globalThis.fetch.mock.calls[0];
712
+ expect(url).toBe("/posts");
713
+ expect(init.method).toBe("POST");
714
+ expect(init.redirect).toBe("error");
715
+ expect(JSON.parse(init.body)).toEqual({ title: "Hi" });
716
+ expect(result).toEqual({ post: { id: 1 } });
717
+ });
718
+
719
+ it("interpolates a :id segment from an object argument into the URL (PUT /posts/5)", async () => {
720
+ mockFetchOk(null, { status: 204, contentType: "text/plain" });
721
+ const updatePost = _makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });
722
+
723
+ await updatePost({ id: 5, title: "Edited" });
724
+
725
+ const [url, init] = globalThis.fetch.mock.calls[0];
726
+ expect(url).toBe("/posts/5");
727
+ expect(init.method).toBe("PATCH");
728
+ // The id stays in the body too — Rails reads it from the path; harmless dup.
729
+ expect(JSON.parse(init.body)).toEqual({ id: 5, title: "Edited" });
730
+ });
731
+
732
+ it("interpolates a :id segment read from FormData and keeps the multipart body", async () => {
733
+ mockFetchOk({ ok: true });
734
+ const fd = new FormData();
735
+ fd.append("id", "42");
736
+ fd.append("title", "x");
737
+ const updatePost = _makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });
738
+
739
+ await updatePost(fd);
740
+
741
+ const [url, init] = globalThis.fetch.mock.calls[0];
742
+ expect(url).toBe("/posts/42");
743
+ expect(init.body).toBe(fd); // FormData passed through (browser sets multipart boundary)
744
+ expect(init.headers["Content-Type"]).toBeUndefined();
745
+ });
746
+
747
+ it("URL-encodes interpolated segment values", async () => {
748
+ mockFetchOk({ ok: true });
749
+ const showThing = _makeServerFunction({ method: "DELETE", path: "/things/:slug", segments: ["slug"] });
750
+
751
+ await showThing({ slug: "a/b c" });
752
+
753
+ expect(globalThis.fetch.mock.calls[0][0]).toBe("/things/a%2Fb%20c");
754
+ });
755
+
756
+ it("throws a clear TypeError when a required segment is missing", async () => {
757
+ mockFetchOk({ ok: true });
758
+ const updatePost = _makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });
759
+
760
+ await expect(updatePost({ title: "no id here" })).rejects.toThrow(/path segment ":id"/);
761
+ expect(globalThis.fetch).not.toHaveBeenCalled();
762
+ });
763
+
764
+ it("injects the CSRF meta-tag token as X-CSRF-Token (NFR27 preserved)", async () => {
765
+ mockFetchOk({ ok: true });
766
+ mockMetaTag("tok-9-3");
767
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
768
+
769
+ await createPost({ title: "x" });
770
+
771
+ expect(globalThis.fetch.mock.calls[0][1].headers["X-CSRF-Token"]).toBe("tok-9-3");
772
+ });
773
+
774
+ it("wraps a non-2xx response in RuactActionError (status + body preserved)", async () => {
775
+ mockFetchError(422, JSON.stringify({ error: "invalid" }));
776
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
777
+
778
+ await expect(createPost({ title: "" })).rejects.toMatchObject({
779
+ name: "RuactActionError",
780
+ status: 422,
781
+ });
782
+ });
783
+
784
+ it("resolves null on an empty (204) body (matches the v1 empty-body contract)", async () => {
785
+ mockFetchOk("", { status: 204, contentType: "text/plain" });
786
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
787
+
788
+ await expect(createPost({})).resolves.toBeNull();
789
+ });
790
+
791
+ it("follows a { $redirect } response via globalThis.__ruact_navigate and resolves null (AC8)", async () => {
792
+ mockFetchOk({ $redirect: "/posts/1" });
793
+ const navSpy = vi.fn();
794
+ globalThis.__ruact_navigate = navSpy;
795
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
796
+
797
+ const result = await createPost({ title: "x" });
798
+
799
+ expect(navSpy).toHaveBeenCalledWith("/posts/1");
800
+ expect(result).toBeNull();
801
+ });
802
+
803
+ it("falls back to window.location.assign for $redirect when no router is installed", async () => {
804
+ mockFetchOk({ $redirect: "/posts/2" });
805
+ globalThis.__ruact_navigate = undefined;
806
+ const assignSpy = vi.fn();
807
+ globalThis.window = { location: { assign: assignSpy } };
808
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
809
+
810
+ const result = await createPost({ title: "x" });
811
+
812
+ expect(assignSpy).toHaveBeenCalledWith("/posts/2");
813
+ expect(result).toBeNull();
814
+ });
815
+
816
+ it("does NOT treat an ordinary object body with no $redirect as a redirect", async () => {
817
+ mockFetchOk({ post: { id: 1 }, $redirect: 42 }); // $redirect not a string → ignored
818
+ const navSpy = vi.fn();
819
+ globalThis.__ruact_navigate = navSpy;
820
+ const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
821
+
822
+ const result = await createPost({});
823
+
824
+ expect(navSpy).not.toHaveBeenCalled();
825
+ expect(result).toEqual({ post: { id: 1 }, $redirect: 42 });
826
+ });
827
+ });