ruact 0.0.4 → 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 +68 -17
- 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 +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- 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
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -15,6 +15,10 @@ let originalFetch;
|
|
|
15
15
|
|
|
16
16
|
beforeEach(() => {
|
|
17
17
|
originalFetch = globalThis.fetch;
|
|
18
|
+
// Story 9.6 — start every case from a clean in-flight registry so a test that
|
|
19
|
+
// deliberately leaves a request pending cannot leak a shared promise into the
|
|
20
|
+
// next one.
|
|
21
|
+
__internals.__resetQueryDedup();
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
afterEach(() => {
|
|
@@ -44,6 +48,39 @@ function mockFetchError(status, bodyText) {
|
|
|
44
48
|
return response;
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
// Story 9.6 — a fetch stub that returns a PENDING promise so two hooks can both
|
|
52
|
+
// be in-flight at once (the precondition for exercising dedup). The test
|
|
53
|
+
// resolves it manually AFTER both hooks have mounted.
|
|
54
|
+
function deferredFetch() {
|
|
55
|
+
let settle;
|
|
56
|
+
const fetchMock = vi.fn(
|
|
57
|
+
() =>
|
|
58
|
+
new Promise((resolve) => {
|
|
59
|
+
settle = resolve;
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
globalThis.fetch = fetchMock;
|
|
63
|
+
return {
|
|
64
|
+
fetchMock,
|
|
65
|
+
resolveOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
|
|
66
|
+
settle({
|
|
67
|
+
ok: true,
|
|
68
|
+
status,
|
|
69
|
+
headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
|
|
70
|
+
text: () => Promise.resolve(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
resolveError(status, bodyText) {
|
|
74
|
+
settle({
|
|
75
|
+
ok: false,
|
|
76
|
+
status,
|
|
77
|
+
headers: { get: () => "text/plain" },
|
|
78
|
+
text: () => Promise.resolve(bodyText),
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
47
84
|
describe("Story 9.5 — useQuery hook contract", () => {
|
|
48
85
|
it("starts loading, then resolves to { data, loading: false, error: null }", async () => {
|
|
49
86
|
const ref = vi.fn().mockResolvedValue({ items: [1, 2] });
|
|
@@ -179,3 +216,153 @@ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
|
|
|
179
216
|
expect(init.redirect).toBe("error");
|
|
180
217
|
});
|
|
181
218
|
});
|
|
219
|
+
|
|
220
|
+
describe("Story 9.6 — useQuery in-flight request de-duplication", () => {
|
|
221
|
+
it("shares ONE network request across concurrent callers (same ref + same params) — AC1", async () => {
|
|
222
|
+
const deferred = deferredFetch();
|
|
223
|
+
const categories = _makeQuery({ path: "/q/categories", kind: "query" });
|
|
224
|
+
|
|
225
|
+
const a = renderHook(() => useQuery(categories, { q: "bo" }));
|
|
226
|
+
const b = renderHook(() => useQuery(categories, { q: "bo" }));
|
|
227
|
+
|
|
228
|
+
// Both hooks are in-flight: the second joined the first's request.
|
|
229
|
+
await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
|
|
230
|
+
|
|
231
|
+
deferred.resolveOk([{ value: 1, label: "Books" }]);
|
|
232
|
+
await waitFor(() => expect(a.result.current.loading).toBe(false));
|
|
233
|
+
await waitFor(() => expect(b.result.current.loading).toBe(false));
|
|
234
|
+
|
|
235
|
+
// Still exactly one network call, and both callers got the same data.
|
|
236
|
+
expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(a.result.current.data).toEqual([{ value: 1, label: "Books" }]);
|
|
238
|
+
expect(b.result.current.data).toEqual([{ value: 1, label: "Books" }]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("propagates the SAME error instance to ALL sharers of an in-flight request — AC1", async () => {
|
|
242
|
+
const deferred = deferredFetch();
|
|
243
|
+
const search = _makeQuery({ path: "/q/search", kind: "query" });
|
|
244
|
+
|
|
245
|
+
const a = renderHook(() => useQuery(search, { q: "x" }));
|
|
246
|
+
const b = renderHook(() => useQuery(search, { q: "x" }));
|
|
247
|
+
await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
|
|
248
|
+
|
|
249
|
+
deferred.resolveError(500, "boom");
|
|
250
|
+
await waitFor(() => expect(a.result.current.loading).toBe(false));
|
|
251
|
+
await waitFor(() => expect(b.result.current.loading).toBe(false));
|
|
252
|
+
|
|
253
|
+
expect(a.result.current.error).toBeInstanceOf(RuactActionError);
|
|
254
|
+
expect(a.result.current.error).toBe(b.result.current.error); // identical instance
|
|
255
|
+
expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("collapses params that differ only in key ORDER onto one request — AC2", async () => {
|
|
259
|
+
const deferred = deferredFetch();
|
|
260
|
+
const search = _makeQuery({ path: "/q/search", kind: "query" });
|
|
261
|
+
|
|
262
|
+
renderHook(() => useQuery(search, { a: 1, b: 2 }));
|
|
263
|
+
renderHook(() => useQuery(search, { b: 2, a: 1 }));
|
|
264
|
+
|
|
265
|
+
await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
|
|
266
|
+
// Give any stray second request a chance to fire before asserting once.
|
|
267
|
+
await Promise.resolve();
|
|
268
|
+
expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("issues SEPARATE requests for different params — AC2", async () => {
|
|
272
|
+
const deferred = deferredFetch();
|
|
273
|
+
const search = _makeQuery({ path: "/q/search", kind: "query" });
|
|
274
|
+
|
|
275
|
+
renderHook(() => useQuery(search, { q: "a" }));
|
|
276
|
+
renderHook(() => useQuery(search, { q: "b" }));
|
|
277
|
+
|
|
278
|
+
await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(2));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("does NOT dedup across distinct references with the same params — AC2 (keys off the reference)", async () => {
|
|
282
|
+
const deferred = deferredFetch();
|
|
283
|
+
const categories = _makeQuery({ path: "/q/categories", kind: "query" });
|
|
284
|
+
const search = _makeQuery({ path: "/q/search", kind: "query" });
|
|
285
|
+
|
|
286
|
+
renderHook(() => useQuery(categories, { q: "x" }));
|
|
287
|
+
renderHook(() => useQuery(search, { q: "x" }));
|
|
288
|
+
|
|
289
|
+
await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(2));
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("does NOT share an invalid-shape params call onto an in-flight no-param request (surfaces the error) — AC2", async () => {
|
|
293
|
+
const deferred = deferredFetch();
|
|
294
|
+
const categories = _makeQuery({ path: "/q/categories", kind: "query" });
|
|
295
|
+
|
|
296
|
+
// A no-param request is in flight (key "").
|
|
297
|
+
const noParams = renderHook(() => useQuery(categories));
|
|
298
|
+
await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
|
|
299
|
+
|
|
300
|
+
// An invalid array-shaped params call must NOT join the "" request — it must
|
|
301
|
+
// invoke the reference, whose buildQueryUrl throws a TypeError.
|
|
302
|
+
const invalid = renderHook(() => useQuery(categories, [1, 2]));
|
|
303
|
+
await waitFor(() => expect(invalid.result.current.loading).toBe(false));
|
|
304
|
+
|
|
305
|
+
expect(invalid.result.current.error).toBeInstanceOf(TypeError);
|
|
306
|
+
expect(invalid.result.current.data).toBeUndefined();
|
|
307
|
+
// No new network call: buildQueryUrl threw before fetch, and it never shared.
|
|
308
|
+
expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
|
|
309
|
+
expect(noParams.result.current.loading).toBe(true); // still in flight
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("issues a FRESH request on a mount AFTER the previous settled (in-flight only, no cache) — AC3", async () => {
|
|
313
|
+
mockFetchOk([{ value: 1 }]);
|
|
314
|
+
const categories = _makeQuery({ path: "/q/categories", kind: "query" });
|
|
315
|
+
|
|
316
|
+
const first = renderHook(() => useQuery(categories, { q: "bo" }));
|
|
317
|
+
await waitFor(() => expect(first.result.current.loading).toBe(false));
|
|
318
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
319
|
+
first.unmount();
|
|
320
|
+
|
|
321
|
+
// The previous request settled, so its registry entry is gone — a new mount
|
|
322
|
+
// with identical reference + params must refetch (no TTL / no SWR).
|
|
323
|
+
const second = renderHook(() => useQuery(categories, { q: "bo" }));
|
|
324
|
+
await waitFor(() => expect(second.result.current.loading).toBe(false));
|
|
325
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("Story 9.6 — canonicalParamsKey (dedup key derivation)", () => {
|
|
330
|
+
const { canonicalParamsKey } = __internals;
|
|
331
|
+
|
|
332
|
+
it("is order-independent ({a,b} === {b,a})", () => {
|
|
333
|
+
expect(canonicalParamsKey({ a: 1, b: 2 })).toBe(canonicalParamsKey({ b: 2, a: 1 }));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("distinguishes different values", () => {
|
|
337
|
+
expect(canonicalParamsKey({ q: "a" })).not.toBe(canonicalParamsKey({ q: "b" }));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("treats null / undefined / empty object as the same empty key", () => {
|
|
341
|
+
expect(canonicalParamsKey(null)).toBe("");
|
|
342
|
+
expect(canonicalParamsKey(undefined)).toBe("");
|
|
343
|
+
expect(canonicalParamsKey({})).toBe("");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("skips undefined-valued keys (mirrors buildQueryUrl's wire omission)", () => {
|
|
347
|
+
expect(canonicalParamsKey({ q: "a", extra: undefined })).toBe(canonicalParamsKey({ q: "a" }));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("distinguishes null from non-finite numbers (NaN / Infinity), which the wire sends differently", () => {
|
|
351
|
+
const asNull = canonicalParamsKey({ q: null });
|
|
352
|
+
const asNaN = canonicalParamsKey({ q: NaN });
|
|
353
|
+
const asInfinity = canonicalParamsKey({ q: Infinity });
|
|
354
|
+
expect(asNull).not.toBe(asNaN);
|
|
355
|
+
expect(asNaN).not.toBe(asInfinity);
|
|
356
|
+
expect(asNull).not.toBe(asInfinity);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("distinguishes a null value (bare key) from the empty string (key=)", () => {
|
|
360
|
+
expect(canonicalParamsKey({ q: null })).not.toBe(canonicalParamsKey({ q: "" }));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("returns null (non-shareable) for shapes buildQueryUrl rejects — array top level or array/object value", () => {
|
|
364
|
+
expect(canonicalParamsKey([1, 2])).toBe(null);
|
|
365
|
+
expect(canonicalParamsKey({ q: [1, 2] })).toBe(null);
|
|
366
|
+
expect(canonicalParamsKey({ q: { deep: 1 } })).toBe(null);
|
|
367
|
+
});
|
|
368
|
+
});
|