@fragno-dev/core 0.0.5 → 0.0.7

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 (69) hide show
  1. package/.turbo/turbo-build.log +47 -55
  2. package/.turbo/turbo-test.log +297 -0
  3. package/CHANGELOG.md +15 -0
  4. package/dist/api/api.d.ts +1 -1
  5. package/dist/api/api.js +1 -1
  6. package/dist/{api-CBDGZiLC.d.ts → api-CAPyac52.d.ts} +3 -3
  7. package/dist/api-CAPyac52.d.ts.map +1 -0
  8. package/dist/{api-DgHfYjq2.js → api-DuzjjCT4.js} +2 -2
  9. package/dist/{api-DgHfYjq2.js.map → api-DuzjjCT4.js.map} +1 -1
  10. package/dist/client/client.d.ts +2 -2
  11. package/dist/client/client.js +4 -4
  12. package/dist/client/client.svelte.d.ts +14 -14
  13. package/dist/client/client.svelte.d.ts.map +1 -1
  14. package/dist/client/client.svelte.js +4 -4
  15. package/dist/client/react.d.ts +12 -12
  16. package/dist/client/react.d.ts.map +1 -1
  17. package/dist/client/react.js +6 -8
  18. package/dist/client/react.js.map +1 -1
  19. package/dist/client/solid.d.ts +45 -0
  20. package/dist/client/solid.d.ts.map +1 -0
  21. package/dist/client/solid.js +110 -0
  22. package/dist/client/solid.js.map +1 -0
  23. package/dist/client/vanilla.d.ts +21 -21
  24. package/dist/client/vanilla.d.ts.map +1 -1
  25. package/dist/client/vanilla.js +4 -4
  26. package/dist/client/vue.d.ts +14 -14
  27. package/dist/client/vue.d.ts.map +1 -1
  28. package/dist/client/vue.js +4 -4
  29. package/dist/{client-Cf53QR-z.js → client-C_Oc8hpD.js} +7 -10
  30. package/dist/{client-Cf53QR-z.js.map → client-C_Oc8hpD.js.map} +1 -1
  31. package/dist/client-DdpPMlXL.d.ts +338 -0
  32. package/dist/client-DdpPMlXL.d.ts.map +1 -0
  33. package/dist/integrations/react-ssr.js +1 -1
  34. package/dist/mod.d.ts +2 -2
  35. package/dist/mod.js +44 -6
  36. package/dist/mod.js.map +1 -1
  37. package/dist/{route-Bp6eByhz.js → route-Dq62lXqB.js} +6 -6
  38. package/dist/route-Dq62lXqB.js.map +1 -0
  39. package/dist/{ssr-B9TeIF01.js → ssr-BAhbA_3q.js} +2 -2
  40. package/dist/{ssr-B9TeIF01.js.map → ssr-BAhbA_3q.js.map} +1 -1
  41. package/package.json +21 -22
  42. package/src/api/fragment.ts +90 -13
  43. package/src/api/request-middleware.test.ts +3 -3
  44. package/src/api/request-output-context.ts +3 -3
  45. package/src/api/route.ts +1 -8
  46. package/src/client/client-error.test.ts +17 -1
  47. package/src/client/solid.test.ts +840 -0
  48. package/src/client/solid.ts +261 -0
  49. package/tsdown.config.ts +1 -0
  50. package/vitest.config.ts +10 -7
  51. package/dist/api-CBDGZiLC.d.ts.map +0 -1
  52. package/dist/client-XFdAy-IQ.d.ts +0 -287
  53. package/dist/client-XFdAy-IQ.d.ts.map +0 -1
  54. package/dist/integrations/astro.d.ts +0 -18
  55. package/dist/integrations/astro.d.ts.map +0 -1
  56. package/dist/integrations/astro.js +0 -16
  57. package/dist/integrations/astro.js.map +0 -1
  58. package/dist/integrations/next-js.d.ts +0 -15
  59. package/dist/integrations/next-js.d.ts.map +0 -1
  60. package/dist/integrations/next-js.js +0 -17
  61. package/dist/integrations/next-js.js.map +0 -1
  62. package/dist/integrations/svelte-kit.d.ts +0 -21
  63. package/dist/integrations/svelte-kit.d.ts.map +0 -1
  64. package/dist/integrations/svelte-kit.js +0 -18
  65. package/dist/integrations/svelte-kit.js.map +0 -1
  66. package/dist/route-Bp6eByhz.js.map +0 -1
  67. package/src/integrations/astro.ts +0 -17
  68. package/src/integrations/next-js.ts +0 -31
  69. package/src/integrations/svelte-kit.ts +0 -41
@@ -0,0 +1,840 @@
1
+ import { test, expect, describe, vi, beforeEach, afterEach, assert } from "vitest";
2
+ import { atom, computed, type ReadableAtom } from "nanostores";
3
+ import { z } from "zod";
4
+ import { createClientBuilder } from "./client";
5
+ import { useFragno, accessorToAtom, isAccessor } from "./solid";
6
+ import { defineRoute } from "../api/route";
7
+ import { defineFragment } from "../api/fragment";
8
+ import type { FragnoPublicClientConfig } from "../mod";
9
+ import { FragnoClientUnknownApiError } from "./client-error";
10
+ import { createSignal, createRoot } from "solid-js";
11
+
12
+ // Mock fetch globally
13
+ global.fetch = vi.fn();
14
+
15
+ async function sleep(ms: number) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ describe("isAccessor", () => {
20
+ test("should return false for ReadableAtom<string>", () => {
21
+ const testAtom: ReadableAtom<string> = atom("test");
22
+ expect(isAccessor(testAtom)).toBe(false);
23
+ });
24
+
25
+ test("should return true for SolidJS Accessor", () => {
26
+ createRoot((dispose) => {
27
+ const [signal] = createSignal("test");
28
+ expect(isAccessor(signal)).toBe(true);
29
+ dispose();
30
+ });
31
+ });
32
+
33
+ test("should return false for string", () => {
34
+ expect(isAccessor("test")).toBe(false);
35
+ });
36
+
37
+ test("should return false for object", () => {
38
+ expect(isAccessor({ value: "test" })).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("accessorToAtom", () => {
43
+ test("should create an atom from an accessor", () => {
44
+ createRoot((dispose) => {
45
+ const [signal, _setSignal] = createSignal(123);
46
+ const a = accessorToAtom(signal);
47
+ expect(a.get()).toBe(123);
48
+ dispose();
49
+ });
50
+ });
51
+
52
+ test("should update the atom when the accessor changes", async () => {
53
+ createRoot((dispose) => {
54
+ const [signal, setSignal] = createSignal(123);
55
+ const a = accessorToAtom(signal);
56
+ expect(a.get()).toBe(123);
57
+ setSignal(456);
58
+ // Give the effect time to run
59
+ setTimeout(() => {
60
+ expect(a.get()).toBe(456);
61
+ dispose();
62
+ }, 0);
63
+ });
64
+ });
65
+ });
66
+
67
+ describe("createSolidHook", () => {
68
+ const clientConfig: FragnoPublicClientConfig = {
69
+ baseUrl: "http://localhost:3000",
70
+ };
71
+
72
+ beforeEach(() => {
73
+ vi.clearAllMocks();
74
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ test("Hook should function", async () => {
82
+ const testFragmentDefinition = defineFragment("test-fragment");
83
+ const testRoutes = [
84
+ defineRoute({
85
+ method: "GET",
86
+ path: "/users",
87
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
88
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
89
+ }),
90
+ ] as const;
91
+
92
+ vi.mocked(global.fetch).mockImplementationOnce(
93
+ async () =>
94
+ ({
95
+ headers: new Headers(),
96
+ ok: true,
97
+ json: async () => [{ id: 1, name: "John" }],
98
+ }) as Response,
99
+ );
100
+
101
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
102
+ const clientObj = {
103
+ useUsers: client.createHook("/users"),
104
+ };
105
+
106
+ await createRoot(async (dispose) => {
107
+ try {
108
+ const { useUsers } = useFragno(clientObj);
109
+ const { loading, data, error } = useUsers();
110
+
111
+ // Wait for loading to complete
112
+ await sleep(1);
113
+
114
+ expect(loading()).toBe(false);
115
+ expect(data()).toEqual([{ id: 1, name: "John" }]);
116
+ expect(error()).toBeUndefined();
117
+ } finally {
118
+ dispose();
119
+ }
120
+ });
121
+ });
122
+
123
+ test("Should support path parameters and update reactively when SolidJS signal changes", async () => {
124
+ const testFragmentDefinition = defineFragment("test-fragment");
125
+ const testRoutes = [
126
+ defineRoute({
127
+ method: "GET",
128
+ path: "/users/:id",
129
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
130
+ handler: async ({ pathParams }, { json }) =>
131
+ json({ id: Number(pathParams["id"]), name: "John" }),
132
+ }),
133
+ ] as const;
134
+
135
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
136
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
137
+ assert(typeof input === "string");
138
+
139
+ // Regex to extract id value from a URL string, matching only on /users/:id
140
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
141
+
142
+ expect(id).toBeDefined();
143
+ expect(+id).not.toBeNaN();
144
+
145
+ return {
146
+ headers: new Headers(),
147
+ ok: true,
148
+ json: async () => ({ id: Number(id), name: "John" }),
149
+ } as Response;
150
+ });
151
+
152
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
153
+ const clientObj = {
154
+ useUser: client.createHook("/users/:id"),
155
+ };
156
+
157
+ await createRoot(async (dispose) => {
158
+ try {
159
+ const [id, setId] = createSignal("123");
160
+
161
+ const { useUser } = useFragno(clientObj);
162
+ const { loading, data, error } = useUser({ path: { id } });
163
+
164
+ // Wait for initial load
165
+ await sleep(1);
166
+
167
+ expect(loading()).toBe(false);
168
+ expect(data()).toEqual({ id: 123, name: "John" });
169
+ expect(error()).toBeUndefined();
170
+
171
+ // Update the id value
172
+ setId("456");
173
+
174
+ // Wait for the update
175
+ await sleep(1);
176
+
177
+ expect(data()).toEqual({ id: 456, name: "John" });
178
+ expect(fetch).toHaveBeenCalledTimes(2);
179
+ } finally {
180
+ dispose();
181
+ }
182
+ });
183
+ });
184
+
185
+ test("Should support path parameters and update reactively when Nanostores Atom changes", async () => {
186
+ const testFragmentDefinition = defineFragment("test-fragment");
187
+ const testRoutes = [
188
+ defineRoute({
189
+ method: "GET",
190
+ path: "/users/:id",
191
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
192
+ handler: async ({ pathParams }, { json }) =>
193
+ json({ id: Number(pathParams["id"]), name: "John" }),
194
+ }),
195
+ ] as const;
196
+
197
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
198
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
199
+ assert(typeof input === "string");
200
+
201
+ // Regex to extract id value from a URL string, matching only on /users/:id
202
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
203
+
204
+ expect(id).toBeDefined();
205
+ expect(+id).not.toBeNaN();
206
+
207
+ return {
208
+ headers: new Headers(),
209
+ ok: true,
210
+ json: async () => ({ id: Number(id), name: "John" }),
211
+ } as Response;
212
+ });
213
+
214
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
215
+ const clientObj = {
216
+ useUser: client.createHook("/users/:id"),
217
+ };
218
+
219
+ await createRoot(async (dispose) => {
220
+ const id = atom("123");
221
+
222
+ const { useUser } = useFragno(clientObj);
223
+ const { loading, data, error } = useUser({ path: { id } });
224
+
225
+ // Wait for initial load
226
+ await sleep(1);
227
+
228
+ expect(loading()).toBe(false);
229
+ expect(data()).toEqual({ id: 123, name: "John" });
230
+ expect(error()).toBeUndefined();
231
+
232
+ // Update the id value
233
+ id.set("456");
234
+
235
+ // Wait for the update
236
+ await sleep(1);
237
+
238
+ expect(data()).toEqual({ id: 456, name: "John" });
239
+ expect(fetch).toHaveBeenCalledTimes(2);
240
+
241
+ dispose();
242
+ });
243
+ });
244
+
245
+ test("Should handle errors gracefully", async () => {
246
+ const testFragmentDefinition = defineFragment("test-fragment");
247
+ const testRoutes = [
248
+ defineRoute({
249
+ method: "GET",
250
+ path: "/users",
251
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
252
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
253
+ }),
254
+ ] as const;
255
+
256
+ vi.mocked(global.fetch).mockImplementationOnce(
257
+ async () =>
258
+ ({
259
+ headers: new Headers(),
260
+ ok: false,
261
+ status: 500,
262
+ statusText: "Internal Server Error",
263
+ json: async () => ({ message: "Server error" }),
264
+ }) as Response,
265
+ );
266
+
267
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
268
+ const clientObj = {
269
+ useUsers: client.createHook("/users"),
270
+ };
271
+
272
+ await createRoot(async (dispose) => {
273
+ const { useUsers } = useFragno(clientObj);
274
+ const { loading, data, error } = useUsers();
275
+
276
+ // Wait for loading to complete
277
+ await sleep(1);
278
+
279
+ expect(loading()).toBe(false);
280
+ expect(data()).toBeUndefined();
281
+ expect(error()).toBeDefined();
282
+ expect(error()).toBeInstanceOf(FragnoClientUnknownApiError);
283
+
284
+ dispose();
285
+ });
286
+ });
287
+
288
+ test("Should handle query parameters", async () => {
289
+ const testFragmentDefinition = defineFragment("test-fragment");
290
+ const testRoutes = [
291
+ defineRoute({
292
+ method: "GET",
293
+ path: "/users",
294
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
295
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
296
+ }),
297
+ ] as const;
298
+
299
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
300
+ assert(typeof input === "string");
301
+ expect(input).toContain("limit=10");
302
+ expect(input).toContain("page=1");
303
+
304
+ return {
305
+ headers: new Headers(),
306
+ ok: true,
307
+ json: async () => [{ id: 1, name: "John" }],
308
+ } as Response;
309
+ });
310
+
311
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
312
+ const clientObj = {
313
+ useUsers: client.createHook("/users"),
314
+ };
315
+
316
+ await createRoot(async (dispose) => {
317
+ const [limit, setLimit] = createSignal("10");
318
+ const [page, setPage] = createSignal("1");
319
+
320
+ const { useUsers } = useFragno(clientObj);
321
+ const { loading, data, error } = useUsers({ query: { limit, page } });
322
+
323
+ // Wait for initial load
324
+ await sleep(1);
325
+
326
+ expect(loading()).toBe(false);
327
+ expect(data()).toEqual([{ id: 1, name: "John" }]);
328
+ expect(error()).toBeUndefined();
329
+
330
+ // Update query params
331
+ setLimit("20");
332
+ setPage("2");
333
+
334
+ // Wait for the update
335
+ await sleep(1);
336
+
337
+ expect(fetch).toHaveBeenCalledTimes(2);
338
+
339
+ // Verify the second call has updated params
340
+ const lastCall = vi.mocked(global.fetch).mock.calls[1];
341
+ expect(lastCall[0]).toContain("limit=20");
342
+ expect(lastCall[0]).toContain("page=2");
343
+
344
+ dispose();
345
+ });
346
+ });
347
+
348
+ test("Should handle multiple hooks together", async () => {
349
+ const testFragmentDefinition = defineFragment("test-fragment");
350
+ const testRoutes = [
351
+ defineRoute({
352
+ method: "GET",
353
+ path: "/users",
354
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
355
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
356
+ }),
357
+ defineRoute({
358
+ method: "GET",
359
+ path: "/posts",
360
+ outputSchema: z.array(z.object({ id: z.number(), title: z.string() })),
361
+ handler: async (_ctx, { json }) => json([{ id: 1, title: "First Post" }]),
362
+ }),
363
+ defineRoute({
364
+ method: "DELETE",
365
+ path: "/users/:id",
366
+ inputSchema: z.object({}),
367
+ outputSchema: z.object({ success: z.boolean() }),
368
+ handler: async (_ctx, { json }) => json({ success: true }),
369
+ }),
370
+ ] as const;
371
+
372
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
373
+ assert(typeof input === "string");
374
+
375
+ if (input.includes("/users") && !input.includes("/users/")) {
376
+ return {
377
+ headers: new Headers(),
378
+ ok: true,
379
+ json: async () => [{ id: 1, name: "John" }],
380
+ } as Response;
381
+ } else if (input.includes("/posts")) {
382
+ return {
383
+ headers: new Headers(),
384
+ ok: true,
385
+ json: async () => [{ id: 1, title: "First Post" }],
386
+ } as Response;
387
+ } else if (input.includes("/users/")) {
388
+ return {
389
+ headers: new Headers(),
390
+ ok: true,
391
+ json: async () => ({ success: true }),
392
+ } as Response;
393
+ }
394
+
395
+ throw new Error(`Unexpected URL: ${input}`);
396
+ });
397
+
398
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
399
+ const clientObj = {
400
+ useUsers: client.createHook("/users"),
401
+ usePosts: client.createHook("/posts"),
402
+ deleteUser: client.createMutator("DELETE", "/users/:id"),
403
+ };
404
+
405
+ await createRoot(async (dispose) => {
406
+ const { useUsers, usePosts, deleteUser } = useFragno(clientObj);
407
+
408
+ // Use multiple hooks
409
+ const users = useUsers();
410
+ const posts = usePosts();
411
+ const deleter = deleteUser();
412
+
413
+ // Wait for loading to complete
414
+ await sleep(1);
415
+
416
+ expect(users.loading()).toBe(false);
417
+ expect(posts.loading()).toBe(false);
418
+ expect(users.data()).toEqual([{ id: 1, name: "John" }]);
419
+ expect(posts.data()).toEqual([{ id: 1, title: "First Post" }]);
420
+
421
+ // Use the mutator
422
+ const result = await deleter.mutate({
423
+ body: {},
424
+ path: { id: "1" },
425
+ });
426
+
427
+ expect(result).toEqual({ success: true });
428
+
429
+ dispose();
430
+ });
431
+ });
432
+
433
+ test("Should handle mixed reactive parameters - signal path param, atom and signal query params, with reactive updates", async () => {
434
+ const testFragmentDefinition = defineFragment("test-fragment");
435
+ const testRoutes = [
436
+ defineRoute({
437
+ method: "GET",
438
+ path: "/users/:id/posts",
439
+ inputSchema: z.object({
440
+ limit: z.string().optional(),
441
+ category: z.string().optional(),
442
+ sort: z.string().optional(),
443
+ }),
444
+ outputSchema: z.array(
445
+ z.object({ id: z.number(), title: z.string(), category: z.string() }),
446
+ ),
447
+ handler: async (_ctx, { empty }) => empty(),
448
+ }),
449
+ ] as const;
450
+
451
+ // Mock fetch to verify URL construction and parameter passing
452
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
453
+ assert(typeof input === "string");
454
+
455
+ // Extract user ID from path
456
+ const [, userId] = String(input).match(/\/users\/([^/]+)\/posts/) ?? [];
457
+ expect(userId).toBeDefined();
458
+ expect(+userId).not.toBeNaN();
459
+
460
+ // Parse query parameters
461
+ const url = new URL(input);
462
+ const limit = url.searchParams.get("limit") || "5";
463
+ const category = url.searchParams.get("category") || "general";
464
+ const sort = url.searchParams.get("sort") || "asc";
465
+
466
+ return {
467
+ headers: new Headers(),
468
+ ok: true,
469
+ json: async () => [
470
+ {
471
+ id: Number(userId) * 100,
472
+ title: `Post for user ${userId}`,
473
+ category: `${category}-${limit}-${sort}`,
474
+ },
475
+ ],
476
+ } as Response;
477
+ });
478
+
479
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
480
+ const clientObj = {
481
+ useUserPosts: client.createHook("/users/:id/posts"),
482
+ };
483
+
484
+ await createRoot(async (dispose) => {
485
+ // Set up reactive parameters
486
+ const [userId, setUserId] = createSignal("1"); // SolidJS signal for path parameter
487
+ const limit = atom("10"); // Nanostores atom for query parameter
488
+ const [category, setCategory] = createSignal("tech"); // SolidJS signal for query parameter
489
+ const sort = "desc"; // Normal string for query parameter
490
+
491
+ const { useUserPosts } = useFragno(clientObj);
492
+ const { loading, data, error } = useUserPosts({
493
+ path: { id: userId },
494
+ query: { limit, category, sort },
495
+ });
496
+
497
+ // Wait for initial load
498
+ await sleep(1);
499
+
500
+ expect(loading()).toBe(false);
501
+ expect(data()).toEqual([{ id: 100, title: "Post for user 1", category: "tech-10-desc" }]);
502
+ expect(error()).toBeUndefined();
503
+ expect(fetch).toHaveBeenCalledTimes(1);
504
+
505
+ // Verify initial URL construction
506
+ const firstCall = vi.mocked(global.fetch).mock.calls[0][0] as string;
507
+ expect(firstCall).toContain("/users/1/posts");
508
+ expect(firstCall).toContain("limit=10");
509
+ expect(firstCall).toContain("category=tech");
510
+ expect(firstCall).toContain("sort=desc");
511
+
512
+ // Update the SolidJS signal path parameter
513
+ setUserId("2");
514
+
515
+ await sleep(1);
516
+
517
+ expect(data()).toEqual([{ id: 200, title: "Post for user 2", category: "tech-10-desc" }]);
518
+ expect(fetch).toHaveBeenCalledTimes(2);
519
+
520
+ // Verify the second call has updated path param
521
+ const secondCall = vi.mocked(global.fetch).mock.calls[1][0] as string;
522
+ expect(secondCall).toContain("/users/2/posts");
523
+ expect(secondCall).toContain("limit=10");
524
+ expect(secondCall).toContain("category=tech");
525
+ expect(secondCall).toContain("sort=desc");
526
+
527
+ // Update the nanostores atom query parameter
528
+ limit.set("20");
529
+
530
+ await sleep(1);
531
+
532
+ expect(data()).toEqual([{ id: 200, title: "Post for user 2", category: "tech-20-desc" }]);
533
+ expect(fetch).toHaveBeenCalledTimes(3);
534
+
535
+ // Verify the third call has updated atom query param
536
+ const thirdCall = vi.mocked(global.fetch).mock.calls[2][0] as string;
537
+ expect(thirdCall).toContain("/users/2/posts");
538
+ expect(thirdCall).toContain("limit=20");
539
+ expect(thirdCall).toContain("category=tech");
540
+ expect(thirdCall).toContain("sort=desc");
541
+
542
+ // Update the SolidJS signal query parameter
543
+ setCategory("science");
544
+
545
+ await sleep(1);
546
+
547
+ expect(data()).toEqual([{ id: 200, title: "Post for user 2", category: "science-20-desc" }]);
548
+ expect(fetch).toHaveBeenCalledTimes(4);
549
+
550
+ // Verify the fourth call has updated signal query param
551
+ const fourthCall = vi.mocked(global.fetch).mock.calls[3][0] as string;
552
+ expect(fourthCall).toContain("/users/2/posts");
553
+ expect(fourthCall).toContain("limit=20");
554
+ expect(fourthCall).toContain("category=science");
555
+ expect(fourthCall).toContain("sort=desc");
556
+
557
+ // Update both reactive parameters simultaneously
558
+ setUserId("3");
559
+ limit.set("5");
560
+ setCategory("news");
561
+
562
+ await sleep(1);
563
+
564
+ expect(data()).toEqual([{ id: 300, title: "Post for user 3", category: "news-5-desc" }]);
565
+ expect(fetch).toHaveBeenCalledTimes(5);
566
+
567
+ // Verify the final call has all updated parameters
568
+ const finalCall = vi.mocked(global.fetch).mock.calls[4][0] as string;
569
+ expect(finalCall).toContain("/users/3/posts");
570
+ expect(finalCall).toContain("limit=5");
571
+ expect(finalCall).toContain("category=news");
572
+ expect(finalCall).toContain("sort=desc"); // Static parameter should remain unchanged
573
+
574
+ dispose();
575
+ });
576
+ });
577
+ });
578
+
579
+ describe("createSolidMutator", () => {
580
+ const clientConfig: FragnoPublicClientConfig = {
581
+ baseUrl: "http://localhost:3000",
582
+ };
583
+
584
+ beforeEach(() => {
585
+ vi.clearAllMocks();
586
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
587
+ });
588
+
589
+ afterEach(() => {
590
+ vi.restoreAllMocks();
591
+ });
592
+
593
+ test("Should handle mutator with path parameters", async () => {
594
+ const testFragmentDefinition = defineFragment("test-fragment");
595
+ const testRoutes = [
596
+ defineRoute({
597
+ method: "PUT",
598
+ path: "/users/:id",
599
+ inputSchema: z.object({ name: z.string() }),
600
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
601
+ handler: async ({ pathParams, input }, { json }) => {
602
+ const { name } = await input.valid();
603
+ return json({ id: Number(pathParams["id"]), name });
604
+ },
605
+ }),
606
+ ] as const;
607
+
608
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
609
+ assert(typeof input === "string");
610
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
611
+
612
+ return {
613
+ headers: new Headers(),
614
+ ok: true,
615
+ json: async () => ({ id: Number(id), name: "Updated Name" }),
616
+ } as Response;
617
+ });
618
+
619
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
620
+ const clientObj = {
621
+ updateUser: client.createMutator("PUT", "/users/:id"),
622
+ };
623
+
624
+ await createRoot(async (dispose) => {
625
+ const { updateUser } = useFragno(clientObj);
626
+ const { mutate, data, error } = updateUser();
627
+
628
+ const [userId, setUserId] = createSignal("42");
629
+ const result = await mutate({
630
+ body: { name: "Updated Name" },
631
+ path: { id: userId },
632
+ });
633
+
634
+ expect(result).toEqual({ id: 42, name: "Updated Name" });
635
+ expect(data()).toEqual({ id: 42, name: "Updated Name" });
636
+ expect(error()).toBeUndefined();
637
+
638
+ // Update the signal and call again
639
+ setUserId("100");
640
+ const result2 = await mutate({
641
+ body: { name: "Another Name" },
642
+ path: { id: userId },
643
+ });
644
+ expect(error()).toBeUndefined();
645
+ expect(result2).toEqual({ id: 100, name: "Updated Name" });
646
+ expect(data()).toEqual({ id: 100, name: "Updated Name" });
647
+
648
+ dispose();
649
+ });
650
+ });
651
+ });
652
+
653
+ describe("useFragno", () => {
654
+ const clientConfig: FragnoPublicClientConfig = {
655
+ baseUrl: "http://localhost:3000",
656
+ };
657
+
658
+ const testFragmentDefinition = defineFragment("test-fragment");
659
+ const testRoutes = [
660
+ defineRoute({
661
+ method: "GET",
662
+ path: "/data",
663
+ outputSchema: z.string(),
664
+ handler: async (_ctx, { json }) => json("test data"),
665
+ }),
666
+ defineRoute({
667
+ method: "POST",
668
+ path: "/action",
669
+ inputSchema: z.object({ value: z.string() }),
670
+ outputSchema: z.object({ result: z.string() }),
671
+ handler: async (_ctx, { json }) => json({ result: "test value" }),
672
+ }),
673
+ ] as const;
674
+
675
+ test("should pass through non-hook values unchanged", () => {
676
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
677
+ const clientObj = {
678
+ useData: client.createHook("/data"),
679
+ usePostAction: client.createMutator("POST", "/action"),
680
+ someString: "hello world",
681
+ someNumber: 42,
682
+ someObject: { foo: "bar", nested: { value: true } },
683
+ someArray: [1, 2, 3],
684
+ someFunction: () => "test",
685
+ someNull: null,
686
+ someUndefined: undefined,
687
+ };
688
+
689
+ const result = useFragno(clientObj);
690
+
691
+ // Check that non-hook values are passed through unchanged
692
+ expect(result.someString).toBe("hello world");
693
+ expect(result.someNumber).toBe(42);
694
+ expect(result.someObject).toEqual({ foo: "bar", nested: { value: true } });
695
+ expect(result.someArray).toEqual([1, 2, 3]);
696
+ expect(result.someFunction()).toBe("test");
697
+ expect(result.someNull).toBeNull();
698
+ expect(result.someUndefined).toBeUndefined();
699
+
700
+ // Verify that hooks are still transformed
701
+ expect(typeof result.useData).toBe("function");
702
+ expect(typeof result.usePostAction).toBe("function");
703
+ });
704
+ });
705
+
706
+ describe("createSolidStore", () => {
707
+ const clientConfig: FragnoPublicClientConfig = {
708
+ baseUrl: "http://localhost:3000",
709
+ };
710
+
711
+ beforeEach(() => {
712
+ vi.clearAllMocks();
713
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
714
+ });
715
+
716
+ afterEach(() => {
717
+ vi.restoreAllMocks();
718
+ });
719
+
720
+ test("should wrap atom fields with useStore", () => {
721
+ const stringAtom: ReadableAtom<string> = atom("hello");
722
+ const numberAtom: ReadableAtom<number> = atom(42);
723
+ const booleanAtom: ReadableAtom<boolean> = atom(true);
724
+
725
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
726
+ const client = {
727
+ useStore: cb.createStore({
728
+ message: stringAtom,
729
+ count: numberAtom,
730
+ isActive: booleanAtom,
731
+ }),
732
+ };
733
+
734
+ createRoot((dispose) => {
735
+ const { useStore } = useFragno(client);
736
+
737
+ const { message, count, isActive } = useStore();
738
+
739
+ // The store should return accessors
740
+ expect(typeof message).toBe("function");
741
+ expect(typeof count).toBe("function");
742
+ expect(typeof isActive).toBe("function");
743
+
744
+ // Calling the accessors should return the values
745
+ expect(message()).toBe("hello");
746
+ expect(count()).toBe(42);
747
+ expect(isActive()).toBe(true);
748
+
749
+ dispose();
750
+ });
751
+ });
752
+
753
+ test("should handle computed stores", () => {
754
+ const baseNumber = atom(10);
755
+ const doubled = computed(baseNumber, (n) => n * 2);
756
+ const tripled = computed(baseNumber, (n) => n * 3);
757
+
758
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
759
+ const client = {
760
+ useComputedValues: cb.createStore({
761
+ base: baseNumber,
762
+ doubled: doubled,
763
+ tripled: tripled,
764
+ }),
765
+ };
766
+
767
+ createRoot((dispose) => {
768
+ const { useComputedValues } = useFragno(client);
769
+
770
+ const { base, doubled, tripled } = useComputedValues();
771
+
772
+ expect(base()).toBe(10);
773
+ expect(doubled()).toBe(20);
774
+ expect(tripled()).toBe(30);
775
+
776
+ // Update base and verify reactivity
777
+ baseNumber.set(7);
778
+
779
+ // Give reactivity time to propagate
780
+ setTimeout(() => {
781
+ expect(base()).toBe(7);
782
+ expect(doubled()).toBe(14);
783
+ expect(tripled()).toBe(21);
784
+
785
+ dispose();
786
+ }, 0);
787
+ });
788
+ });
789
+
790
+ test("should pass through non-atom values unchanged", () => {
791
+ const messageAtom: ReadableAtom<string> = atom("test");
792
+ const regularFunction = (x: number) => x * 2;
793
+ const regularObject = { foo: "bar", baz: 123 };
794
+
795
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
796
+ const client = {
797
+ useMixed: cb.createStore({
798
+ message: messageAtom,
799
+ multiply: regularFunction,
800
+ config: regularObject,
801
+ constant: 42,
802
+ }),
803
+ };
804
+
805
+ createRoot((dispose) => {
806
+ const { useMixed } = useFragno(client);
807
+ const { message, config, constant, multiply } = useMixed();
808
+
809
+ // Atom should be wrapped
810
+ expect(typeof message).toBe("function");
811
+ expect(message()).toBe("test");
812
+
813
+ // Non-atom values should be passed through
814
+ expect(multiply(5)).toBe(10);
815
+ expect(config).toEqual({ foo: "bar", baz: 123 });
816
+ expect(constant).toBe(42);
817
+
818
+ dispose();
819
+ });
820
+ });
821
+
822
+ test("should handle single atom as store", () => {
823
+ const singleAtom: ReadableAtom<string> = atom("single");
824
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
825
+
826
+ const client = {
827
+ useSingle: cb.createStore(singleAtom),
828
+ };
829
+
830
+ createRoot((dispose) => {
831
+ const { useSingle } = useFragno(client);
832
+
833
+ // Single atom should be wrapped as accessor
834
+ expect(typeof useSingle).toBe("function");
835
+ expect(useSingle()).toBe("single");
836
+
837
+ dispose();
838
+ });
839
+ });
840
+ });