ruact 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Vitest suite for the route-driven server-functions runtime.
|
|
2
2
|
//
|
|
3
|
-
// Covers
|
|
4
|
-
// CSRF meta-tag injection, success vs.
|
|
5
|
-
//
|
|
3
|
+
// Covers the shared mutation fetch core via `_makeServerFunction`: argument-
|
|
4
|
+
// shape branching (JSON vs FormData), CSRF meta-tag injection, success vs.
|
|
5
|
+
// failure response handling, error wrapping, and the useActionState two-arg
|
|
6
|
+
// shape. Uses `vi.fn()` to stub `fetch` — no real network. Story 9.9 demolished
|
|
7
|
+
// the v1 `_makeRef` accessor; these tests now exercise the same core through the
|
|
8
|
+
// route-driven accessor (which `_makeRef` used to share verbatim).
|
|
6
9
|
|
|
7
10
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
8
11
|
import {
|
|
9
|
-
_makeRef,
|
|
10
12
|
_makeServerFunction,
|
|
11
13
|
__RUNTIME_VERSION__,
|
|
12
14
|
__internals,
|
|
@@ -15,6 +17,13 @@ import {
|
|
|
15
17
|
revalidate,
|
|
16
18
|
} from "./index.js";
|
|
17
19
|
|
|
20
|
+
// Helper — a route-driven accessor targeting `POST /posts` (the canonical
|
|
21
|
+
// mutation shape). The shared fetch core is identical regardless of verb/path,
|
|
22
|
+
// so this stands in for the demolished `_makeRef("name")` in the core tests.
|
|
23
|
+
function makePostFn() {
|
|
24
|
+
return _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
let originalFetch;
|
|
19
28
|
let originalDocument;
|
|
20
29
|
|
|
@@ -67,27 +76,27 @@ function mockMetaTag(token) {
|
|
|
67
76
|
};
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
describe("
|
|
79
|
+
describe("runtime — accessor basics", () => {
|
|
71
80
|
it("exports the runtime-version sentinel (placeholder __PLACEHOLDER__ is gone)", () => {
|
|
72
81
|
expect(__RUNTIME_VERSION__).toBe(1);
|
|
73
82
|
});
|
|
74
83
|
|
|
75
84
|
it("returns a callable accessor", () => {
|
|
76
|
-
const ref =
|
|
85
|
+
const ref = makePostFn();
|
|
77
86
|
expect(typeof ref).toBe("function");
|
|
78
87
|
});
|
|
79
88
|
});
|
|
80
89
|
|
|
81
|
-
describe("
|
|
82
|
-
it("
|
|
90
|
+
describe("mutation core — JSON body branch", () => {
|
|
91
|
+
it("sends JSON.stringify(args) with Content-Type: application/json over the real path+verb", async () => {
|
|
83
92
|
mockFetchOk({ ok: true });
|
|
84
93
|
mockMetaTag(null);
|
|
85
94
|
|
|
86
|
-
await
|
|
95
|
+
await makePostFn()({ title: "Hi" });
|
|
87
96
|
|
|
88
97
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
89
98
|
const [url, init] = globalThis.fetch.mock.calls[0];
|
|
90
|
-
expect(url).toBe("/
|
|
99
|
+
expect(url).toBe("/posts");
|
|
91
100
|
expect(init.method).toBe("POST");
|
|
92
101
|
expect(init.credentials).toBe("same-origin");
|
|
93
102
|
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
@@ -97,7 +106,7 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
97
106
|
it("treats undefined args as an empty JSON object {}", async () => {
|
|
98
107
|
mockFetchOk({});
|
|
99
108
|
mockMetaTag(null);
|
|
100
|
-
await
|
|
109
|
+
await makePostFn()();
|
|
101
110
|
|
|
102
111
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
103
112
|
expect(init.body).toBe("{}");
|
|
@@ -106,7 +115,7 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
106
115
|
it("treats null args as an empty JSON object {}", async () => {
|
|
107
116
|
mockFetchOk({});
|
|
108
117
|
mockMetaTag(null);
|
|
109
|
-
await
|
|
118
|
+
await makePostFn()(null);
|
|
110
119
|
|
|
111
120
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
112
121
|
expect(init.body).toBe("{}");
|
|
@@ -115,14 +124,14 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
115
124
|
it("resolves with parsed JSON for application/json responses", async () => {
|
|
116
125
|
mockFetchOk({ id: 7 });
|
|
117
126
|
mockMetaTag(null);
|
|
118
|
-
const result = await
|
|
127
|
+
const result = await makePostFn()({ title: "x" });
|
|
119
128
|
expect(result).toEqual({ id: 7 });
|
|
120
129
|
});
|
|
121
130
|
|
|
122
131
|
it("attaches Accept: application/json header (re-run-2 #8 — host respond_to branching)", async () => {
|
|
123
132
|
mockFetchOk({});
|
|
124
133
|
mockMetaTag(null);
|
|
125
|
-
await
|
|
134
|
+
await makePostFn()({});
|
|
126
135
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
127
136
|
expect(init.headers.Accept).toBe("application/json");
|
|
128
137
|
});
|
|
@@ -138,7 +147,7 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
138
147
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
139
148
|
mockMetaTag(null);
|
|
140
149
|
|
|
141
|
-
const result = await
|
|
150
|
+
const result = await makePostFn()({});
|
|
142
151
|
expect(result).toBe("hello");
|
|
143
152
|
expect(r.text).toHaveBeenCalled();
|
|
144
153
|
expect(r.json).not.toHaveBeenCalled();
|
|
@@ -156,7 +165,7 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
156
165
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
157
166
|
mockMetaTag(null);
|
|
158
167
|
|
|
159
|
-
const result = await
|
|
168
|
+
const result = await makePostFn()({});
|
|
160
169
|
expect(result).toBeNull();
|
|
161
170
|
expect(r.json).not.toHaveBeenCalled();
|
|
162
171
|
});
|
|
@@ -172,7 +181,7 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
172
181
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
173
182
|
mockMetaTag(null);
|
|
174
183
|
|
|
175
|
-
const result = await
|
|
184
|
+
const result = await makePostFn()({});
|
|
176
185
|
expect(result).toBeNull();
|
|
177
186
|
});
|
|
178
187
|
|
|
@@ -190,21 +199,21 @@ describe("Story 8.1 — JSON body branch", () => {
|
|
|
190
199
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
191
200
|
mockMetaTag(null);
|
|
192
201
|
|
|
193
|
-
const result = await
|
|
202
|
+
const result = await makePostFn()({});
|
|
194
203
|
expect(result).toBeNull();
|
|
195
204
|
expect(r.json).not.toHaveBeenCalled();
|
|
196
205
|
});
|
|
197
206
|
});
|
|
198
207
|
|
|
199
|
-
describe("
|
|
200
|
-
it("
|
|
208
|
+
describe("mutation core — FormData branch", () => {
|
|
209
|
+
it("sends the FormData as-is with NO manual Content-Type header (browser sets boundary)", async () => {
|
|
201
210
|
mockFetchOk({ ok: true });
|
|
202
211
|
mockMetaTag(null);
|
|
203
212
|
|
|
204
213
|
const fd = new FormData();
|
|
205
214
|
fd.append("title", "From form");
|
|
206
215
|
|
|
207
|
-
await
|
|
216
|
+
await makePostFn()(fd);
|
|
208
217
|
|
|
209
218
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
210
219
|
expect(init.body).toBe(fd);
|
|
@@ -212,12 +221,12 @@ describe("Story 8.1 — FormData branch", () => {
|
|
|
212
221
|
});
|
|
213
222
|
});
|
|
214
223
|
|
|
215
|
-
describe("
|
|
224
|
+
describe("mutation core — CSRF header injection", () => {
|
|
216
225
|
it("attaches X-CSRF-Token header when <meta name=\"csrf-token\"> is present", async () => {
|
|
217
226
|
mockFetchOk({ ok: true });
|
|
218
227
|
mockMetaTag("token-abc123");
|
|
219
228
|
|
|
220
|
-
await
|
|
229
|
+
await makePostFn()({ title: "x" });
|
|
221
230
|
|
|
222
231
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
223
232
|
expect(init.headers["X-CSRF-Token"]).toBe("token-abc123");
|
|
@@ -227,7 +236,7 @@ describe("Story 8.1 — CSRF header injection", () => {
|
|
|
227
236
|
mockFetchOk({ ok: true });
|
|
228
237
|
mockMetaTag(null);
|
|
229
238
|
|
|
230
|
-
await
|
|
239
|
+
await makePostFn()({ title: "x" });
|
|
231
240
|
|
|
232
241
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
233
242
|
expect(init.headers["X-CSRF-Token"]).toBeUndefined();
|
|
@@ -237,17 +246,17 @@ describe("Story 8.1 — CSRF header injection", () => {
|
|
|
237
246
|
mockFetchOk({ ok: true });
|
|
238
247
|
globalThis.document = undefined;
|
|
239
248
|
|
|
240
|
-
await expect(
|
|
249
|
+
await expect(makePostFn()({ title: "x" })).resolves.toEqual({ ok: true });
|
|
241
250
|
});
|
|
242
251
|
});
|
|
243
252
|
|
|
244
|
-
describe("
|
|
253
|
+
describe("mutation core — error responses", () => {
|
|
245
254
|
it("rejects with a structured Error on 4xx responses", async () => {
|
|
246
255
|
mockFetchError(422, "validation failed");
|
|
247
256
|
mockMetaTag(null);
|
|
248
257
|
|
|
249
|
-
await expect(
|
|
250
|
-
/ruact action
|
|
258
|
+
await expect(makePostFn()({ title: "" })).rejects.toThrow(
|
|
259
|
+
/ruact action :\/posts failed: 422 validation failed/,
|
|
251
260
|
);
|
|
252
261
|
});
|
|
253
262
|
|
|
@@ -255,8 +264,8 @@ describe("Story 8.1 — error responses", () => {
|
|
|
255
264
|
mockFetchError(500, "boom");
|
|
256
265
|
mockMetaTag(null);
|
|
257
266
|
|
|
258
|
-
await expect(
|
|
259
|
-
/ruact action
|
|
267
|
+
await expect(makePostFn()({})).rejects.toThrow(
|
|
268
|
+
/ruact action :\/posts failed: 500 boom/,
|
|
260
269
|
);
|
|
261
270
|
});
|
|
262
271
|
|
|
@@ -264,13 +273,13 @@ describe("Story 8.1 — error responses", () => {
|
|
|
264
273
|
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
|
|
265
274
|
mockMetaTag(null);
|
|
266
275
|
|
|
267
|
-
await expect(
|
|
268
|
-
/ruact action
|
|
276
|
+
await expect(makePostFn()({})).rejects.toThrow(
|
|
277
|
+
/ruact action :\/posts request failed: Failed to fetch/,
|
|
269
278
|
);
|
|
270
279
|
});
|
|
271
280
|
});
|
|
272
281
|
|
|
273
|
-
describe("
|
|
282
|
+
describe("mutation core — RuactActionError carries status/body", () => {
|
|
274
283
|
it("rejects with a RuactActionError exposing status and parsed JSON body on 422", async () => {
|
|
275
284
|
const r = {
|
|
276
285
|
ok: false,
|
|
@@ -284,13 +293,13 @@ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)",
|
|
|
284
293
|
|
|
285
294
|
let captured = null;
|
|
286
295
|
try {
|
|
287
|
-
await
|
|
296
|
+
await makePostFn()({ title: "" });
|
|
288
297
|
} catch (err) {
|
|
289
298
|
captured = err;
|
|
290
299
|
}
|
|
291
300
|
expect(captured).toBeInstanceOf(RuactActionError);
|
|
292
301
|
expect(captured.status).toBe(422);
|
|
293
|
-
expect(captured.actionName).toBe("
|
|
302
|
+
expect(captured.actionName).toBe("/posts");
|
|
294
303
|
expect(captured.body).toEqual({ errors: { title: ["can't be blank"] } });
|
|
295
304
|
});
|
|
296
305
|
|
|
@@ -300,7 +309,7 @@ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)",
|
|
|
300
309
|
|
|
301
310
|
let captured = null;
|
|
302
311
|
try {
|
|
303
|
-
await
|
|
312
|
+
await makePostFn()({});
|
|
304
313
|
} catch (err) {
|
|
305
314
|
captured = err;
|
|
306
315
|
}
|
|
@@ -310,7 +319,7 @@ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)",
|
|
|
310
319
|
});
|
|
311
320
|
});
|
|
312
321
|
|
|
313
|
-
describe("
|
|
322
|
+
describe("mutation core — +json structured-syntax-suffix media types", () => {
|
|
314
323
|
it("parses application/problem+json as JSON (RFC 6838 §4.2.8)", async () => {
|
|
315
324
|
const r = {
|
|
316
325
|
ok: true,
|
|
@@ -322,7 +331,7 @@ describe("Story 8.1 — Re-run-4 — +json structured-syntax-suffix media types
|
|
|
322
331
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
323
332
|
mockMetaTag(null);
|
|
324
333
|
|
|
325
|
-
const result = await
|
|
334
|
+
const result = await makePostFn()({});
|
|
326
335
|
expect(result).toEqual({ type: "about:blank", title: "ok" });
|
|
327
336
|
});
|
|
328
337
|
|
|
@@ -337,24 +346,12 @@ describe("Story 8.1 — Re-run-4 — +json structured-syntax-suffix media types
|
|
|
337
346
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
338
347
|
mockMetaTag(null);
|
|
339
348
|
|
|
340
|
-
const result = await
|
|
349
|
+
const result = await makePostFn()({});
|
|
341
350
|
expect(result).toEqual({ data: { id: "1" } });
|
|
342
351
|
});
|
|
343
352
|
});
|
|
344
353
|
|
|
345
|
-
describe("
|
|
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)", () => {
|
|
354
|
+
describe("mutation core — Content-Type matching is case-insensitive", () => {
|
|
358
355
|
it("parses JSON when Content-Type is `Application/JSON` (RFC 9110 — case-insensitive media type)", async () => {
|
|
359
356
|
const r = {
|
|
360
357
|
ok: true,
|
|
@@ -366,24 +363,24 @@ describe("Story 8.1 — Re-run-3 — Content-Type matching is case-insensitive (
|
|
|
366
363
|
globalThis.fetch = vi.fn().mockResolvedValue(r);
|
|
367
364
|
mockMetaTag(null);
|
|
368
365
|
|
|
369
|
-
const result = await
|
|
366
|
+
const result = await makePostFn()({});
|
|
370
367
|
expect(result).toEqual({ id: 42 });
|
|
371
368
|
});
|
|
372
369
|
});
|
|
373
370
|
|
|
374
|
-
describe("
|
|
371
|
+
describe("mutation core — fetch redirect: 'error'", () => {
|
|
375
372
|
it("sets redirect: 'error' on the fetch init so auth `redirect_to` failures surface as errors", async () => {
|
|
376
373
|
mockFetchOk({ ok: true });
|
|
377
374
|
mockMetaTag(null);
|
|
378
375
|
|
|
379
|
-
await
|
|
376
|
+
await makePostFn()({});
|
|
380
377
|
|
|
381
378
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
382
379
|
expect(init.redirect).toBe("error");
|
|
383
380
|
});
|
|
384
381
|
});
|
|
385
382
|
|
|
386
|
-
describe("
|
|
383
|
+
describe("mutation core — configureRuactRuntime", () => {
|
|
387
384
|
afterEach(() => {
|
|
388
385
|
configureRuactRuntime({ defaultHeaders: null });
|
|
389
386
|
});
|
|
@@ -393,7 +390,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
|
|
|
393
390
|
mockMetaTag(null);
|
|
394
391
|
configureRuactRuntime({ defaultHeaders: { Authorization: "Bearer abc" } });
|
|
395
392
|
|
|
396
|
-
await
|
|
393
|
+
await makePostFn()({});
|
|
397
394
|
|
|
398
395
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
399
396
|
expect(init.headers.Authorization).toBe("Bearer abc");
|
|
@@ -410,8 +407,8 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
|
|
|
410
407
|
},
|
|
411
408
|
});
|
|
412
409
|
|
|
413
|
-
await
|
|
414
|
-
await
|
|
410
|
+
await makePostFn()({});
|
|
411
|
+
await makePostFn()({});
|
|
415
412
|
|
|
416
413
|
expect(globalThis.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer t1");
|
|
417
414
|
expect(globalThis.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer t2");
|
|
@@ -428,7 +425,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
|
|
|
428
425
|
},
|
|
429
426
|
});
|
|
430
427
|
|
|
431
|
-
await
|
|
428
|
+
await makePostFn()({});
|
|
432
429
|
|
|
433
430
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
434
431
|
expect(init.headers["X-CSRF-Token"]).toBe("real-csrf");
|
|
@@ -458,7 +455,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
|
|
|
458
455
|
},
|
|
459
456
|
});
|
|
460
457
|
|
|
461
|
-
await
|
|
458
|
+
await makePostFn()({});
|
|
462
459
|
|
|
463
460
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
464
461
|
expect(init.headers.Accept).toBe("application/json");
|
|
@@ -483,7 +480,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
|
|
|
483
480
|
|
|
484
481
|
const fd = new FormData();
|
|
485
482
|
fd.append("title", "Hello");
|
|
486
|
-
await
|
|
483
|
+
await makePostFn()(fd);
|
|
487
484
|
|
|
488
485
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
489
486
|
expect(init.headers["Content-Type"]).toBeUndefined();
|
|
@@ -508,12 +505,12 @@ describe("Story 8.1 — __internals (test-only surface)", () => {
|
|
|
508
505
|
// Story 8.2 — useActionState two-arg invocation
|
|
509
506
|
// =============================================================================
|
|
510
507
|
|
|
511
|
-
describe("Story 8.2 —
|
|
508
|
+
describe("Story 8.2 — call-shape detection (useActionState two-arg)", () => {
|
|
512
509
|
it("fn() — zero args sends an empty JSON body (parity with Story 8.1 fn() shape)", async () => {
|
|
513
510
|
mockFetchOk({});
|
|
514
511
|
mockMetaTag(null);
|
|
515
512
|
|
|
516
|
-
await
|
|
513
|
+
await makePostFn()();
|
|
517
514
|
|
|
518
515
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
519
516
|
expect(init.body).toBe("{}");
|
|
@@ -524,7 +521,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
|
|
|
524
521
|
mockFetchOk({});
|
|
525
522
|
mockMetaTag(null);
|
|
526
523
|
|
|
527
|
-
await
|
|
524
|
+
await makePostFn()({ title: "Hi" });
|
|
528
525
|
|
|
529
526
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
530
527
|
expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
|
|
@@ -536,7 +533,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
|
|
|
536
533
|
|
|
537
534
|
const fd = new FormData();
|
|
538
535
|
fd.append("title", "Hi");
|
|
539
|
-
await
|
|
536
|
+
await makePostFn()(fd);
|
|
540
537
|
|
|
541
538
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
542
539
|
expect(init.body).toBe(fd);
|
|
@@ -550,7 +547,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
|
|
|
550
547
|
|
|
551
548
|
const fd = new FormData();
|
|
552
549
|
fd.append("title", "From form");
|
|
553
|
-
await
|
|
550
|
+
await makePostFn()({ message: "previous state" }, fd);
|
|
554
551
|
|
|
555
552
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
556
553
|
expect(init.body).toBe(fd);
|
|
@@ -562,7 +559,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
|
|
|
562
559
|
mockFetchOk({});
|
|
563
560
|
mockMetaTag(null);
|
|
564
561
|
|
|
565
|
-
await
|
|
562
|
+
await makePostFn()({ message: "previous state" }, { title: "Hi" });
|
|
566
563
|
|
|
567
564
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
568
565
|
expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
|
|
@@ -576,16 +573,16 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
|
|
|
576
573
|
|
|
577
574
|
const fd = new FormData();
|
|
578
575
|
fd.append("title", "FD");
|
|
579
|
-
await
|
|
576
|
+
await makePostFn()(fd, { title: "obj" });
|
|
580
577
|
|
|
581
578
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
582
579
|
expect(init.body).toBe(fd);
|
|
583
580
|
});
|
|
584
581
|
|
|
585
|
-
it("fn(a, b, c) — three or more args
|
|
586
|
-
expect(()
|
|
587
|
-
expect(()
|
|
588
|
-
/ruact
|
|
582
|
+
it("fn(a, b, c) — three or more args rejects with a descriptive TypeError", async () => {
|
|
583
|
+
await expect(makePostFn()(1, 2, 3)).rejects.toThrow(TypeError);
|
|
584
|
+
await expect(makePostFn()(1, 2, 3)).rejects.toThrow(
|
|
585
|
+
/ruact server function POST \/posts called with 3 arguments — expected 0, 1, or 2/,
|
|
589
586
|
);
|
|
590
587
|
});
|
|
591
588
|
|
|
@@ -601,7 +598,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
|
|
|
601
598
|
// Pre-Story-8.2 this would have thrown on JSON.stringify(circular). The
|
|
602
599
|
// wire path never sees prevState, so circular references are harmless.
|
|
603
600
|
await expect(
|
|
604
|
-
|
|
601
|
+
makePostFn()(circular, fd),
|
|
605
602
|
).resolves.not.toThrow();
|
|
606
603
|
|
|
607
604
|
const [, init] = globalThis.fetch.mock.calls[0];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruact-server-functions-runtime",
|
|
3
3
|
"version": "0.4.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Route-driven server-functions runtime for the ruact gem. Provides `_makeServerFunction(descriptor)` (mutations: POST/PUT/PATCH/DELETE with CSRF + JSON / FormData), `revalidate(path?)` (Flight refetch), and `_makeQuery(descriptor)` + `useQuery(ref, params?)` (reads: GET /q/<jsId>, CSRF-free, FR88 primitive params → { data, loading, error }; identical concurrent calls share one in-flight request).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Story 14.2 (FR104) — the bootstrap virtual module.
|
|
2
|
+
//
|
|
3
|
+
// ruact's React entry is no longer a `app/javascript/application.jsx` file in
|
|
4
|
+
// the user's tree; it is served as the virtual module `virtual:ruact/bootstrap`
|
|
5
|
+
// from gem-shipped source (`runtime/bootstrap.jsx`), mirroring the existing
|
|
6
|
+
// `virtual:ruact/registry` pattern. This file covers the plugin contract:
|
|
7
|
+
//
|
|
8
|
+
// 1. resolveId maps the public id → the `\0`-prefixed resolved id.
|
|
9
|
+
// 2. load serves the bootstrap source, importing `virtual:ruact/registry` +
|
|
10
|
+
// React (from the app root → single React instance) + the runtime modules
|
|
11
|
+
// via ABSOLUTE fs specifiers (relative imports do not resolve from a
|
|
12
|
+
// `\0virtual:` id).
|
|
13
|
+
// 3. generateBootstrapSource rewrites ONLY the import specifiers (not prose
|
|
14
|
+
// mentions in comments) and emits no JSX (loads via Vite's default loader).
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from "vitest";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import ruact, {
|
|
21
|
+
BOOTSTRAP_VIRTUAL_ID,
|
|
22
|
+
REGISTRY_VIRTUAL_ID,
|
|
23
|
+
generateBootstrapSource,
|
|
24
|
+
} from "./index.js";
|
|
25
|
+
|
|
26
|
+
const RESOLVED = "\0" + BOOTSTRAP_VIRTUAL_ID;
|
|
27
|
+
|
|
28
|
+
describe("virtual:ruact/bootstrap — resolveId (Story 14.2)", () => {
|
|
29
|
+
it("resolves the public id to the \\0-prefixed resolved id", () => {
|
|
30
|
+
const plugin = ruact();
|
|
31
|
+
expect(plugin.resolveId(BOOTSTRAP_VIRTUAL_ID)).toBe(RESOLVED);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns null for unrelated ids (and leaves the registry id intact)", () => {
|
|
35
|
+
const plugin = ruact();
|
|
36
|
+
expect(plugin.resolveId("react")).toBeNull();
|
|
37
|
+
// The bootstrap branch must not regress the registry branch.
|
|
38
|
+
expect(plugin.resolveId(REGISTRY_VIRTUAL_ID)).toBe("\0" + REGISTRY_VIRTUAL_ID);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("virtual:ruact/bootstrap — load (Story 14.2)", () => {
|
|
43
|
+
const plugin = ruact();
|
|
44
|
+
const src = plugin.load(RESOLVED);
|
|
45
|
+
|
|
46
|
+
it("imports virtual:ruact/registry (10.1b auto-registry preserved)", () => {
|
|
47
|
+
expect(src).toContain("from 'virtual:ruact/registry'");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("imports React and react-dom/client as BARE specifiers (single React from app root)", () => {
|
|
51
|
+
expect(src).toMatch(/from 'react'/);
|
|
52
|
+
expect(src).toContain("from 'react-dom/client'");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("imports the runtime modules via ABSOLUTE fs specifiers, not relative", () => {
|
|
56
|
+
expect(src).toMatch(/from '\/.*\/runtime\/flight-client\.js'/);
|
|
57
|
+
expect(src).toMatch(/from '\/.*\/runtime\/ruact-router\.js'/);
|
|
58
|
+
// No `\0virtual:`-unresolvable relative import survives.
|
|
59
|
+
expect(src).not.toMatch(/from\s+['"]\.\/flight-client\.js['"]/);
|
|
60
|
+
expect(src).not.toMatch(/from\s+['"]\.\/ruact-router\.js['"]/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("contains no JSX syntax (loads via the default loader like the registry)", () => {
|
|
64
|
+
expect(src).not.toContain("<App");
|
|
65
|
+
expect(src).toContain("createElement(App)");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns null for the registry id via the same load hook (no cross-wiring)", () => {
|
|
69
|
+
// The registry load still works; the bootstrap branch is additive.
|
|
70
|
+
expect(plugin.load("\0" + REGISTRY_VIRTUAL_ID)).toContain("MODULE_REGISTRY");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("generateBootstrapSource — rewrites imports only (Story 14.2)", () => {
|
|
75
|
+
it("rewrites the runtime import specifiers but leaves a prose mention untouched", () => {
|
|
76
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ruact-boot-")));
|
|
77
|
+
// A bootstrap whose COMMENT mentions ./flight-client.js and whose IMPORT
|
|
78
|
+
// uses it — only the import specifier must be rewritten.
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
path.join(dir, "bootstrap.jsx"),
|
|
81
|
+
[
|
|
82
|
+
"// reads ./flight-client.js at boot",
|
|
83
|
+
"import { createFromFlightPayload } from './flight-client.js';",
|
|
84
|
+
"import { setupRouter } from './ruact-router.js';",
|
|
85
|
+
"export const ok = true;",
|
|
86
|
+
].join("\n"),
|
|
87
|
+
);
|
|
88
|
+
const out = generateBootstrapSource(dir);
|
|
89
|
+
const fc = path.join(dir, "flight-client.js").replace(/\\/g, "/");
|
|
90
|
+
expect(out).toContain(`from '${fc}'`);
|
|
91
|
+
// The comment's bare mention is preserved (not a `from '...'` import).
|
|
92
|
+
expect(out).toContain("// reads ./flight-client.js at boot");
|
|
93
|
+
expect(out).not.toMatch(/from\s+['"]\.\/flight-client\.js['"]/);
|
|
94
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
});
|
|
96
|
+
});
|