@fragno-dev/core 0.1.10 → 0.2.0

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 +139 -131
  2. package/CHANGELOG.md +63 -0
  3. package/dist/api/api.d.ts +23 -5
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js.map +1 -1
  6. package/dist/api/fragment-definition-builder.d.ts +17 -7
  7. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  8. package/dist/api/fragment-definition-builder.js +3 -2
  9. package/dist/api/fragment-definition-builder.js.map +1 -1
  10. package/dist/api/fragment-instantiator.d.ts +129 -32
  11. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  12. package/dist/api/fragment-instantiator.js +232 -50
  13. package/dist/api/fragment-instantiator.js.map +1 -1
  14. package/dist/api/request-input-context.d.ts +57 -1
  15. package/dist/api/request-input-context.d.ts.map +1 -1
  16. package/dist/api/request-input-context.js +67 -0
  17. package/dist/api/request-input-context.js.map +1 -1
  18. package/dist/api/request-middleware.d.ts +1 -1
  19. package/dist/api/request-middleware.d.ts.map +1 -1
  20. package/dist/api/request-middleware.js.map +1 -1
  21. package/dist/api/route.d.ts +7 -7
  22. package/dist/api/route.d.ts.map +1 -1
  23. package/dist/api/route.js.map +1 -1
  24. package/dist/client/client.d.ts +4 -3
  25. package/dist/client/client.d.ts.map +1 -1
  26. package/dist/client/client.js +103 -7
  27. package/dist/client/client.js.map +1 -1
  28. package/dist/client/vue.d.ts +7 -3
  29. package/dist/client/vue.d.ts.map +1 -1
  30. package/dist/client/vue.js +16 -1
  31. package/dist/client/vue.js.map +1 -1
  32. package/dist/internal/trace-context.d.ts +23 -0
  33. package/dist/internal/trace-context.d.ts.map +1 -0
  34. package/dist/internal/trace-context.js +14 -0
  35. package/dist/internal/trace-context.js.map +1 -0
  36. package/dist/mod-client.d.ts +5 -27
  37. package/dist/mod-client.d.ts.map +1 -1
  38. package/dist/mod-client.js +50 -13
  39. package/dist/mod-client.js.map +1 -1
  40. package/dist/mod.d.ts +4 -3
  41. package/dist/mod.js +2 -1
  42. package/dist/runtime.d.ts +15 -0
  43. package/dist/runtime.d.ts.map +1 -0
  44. package/dist/runtime.js +33 -0
  45. package/dist/runtime.js.map +1 -0
  46. package/dist/test/test.d.ts +2 -2
  47. package/dist/test/test.d.ts.map +1 -1
  48. package/dist/test/test.js.map +1 -1
  49. package/package.json +31 -18
  50. package/src/api/api.ts +24 -0
  51. package/src/api/fragment-definition-builder.ts +36 -17
  52. package/src/api/fragment-instantiator.test.ts +429 -1
  53. package/src/api/fragment-instantiator.ts +572 -58
  54. package/src/api/internal/path-runtime.test.ts +7 -0
  55. package/src/api/request-input-context.test.ts +152 -0
  56. package/src/api/request-input-context.ts +85 -0
  57. package/src/api/request-middleware.test.ts +47 -1
  58. package/src/api/request-middleware.ts +1 -1
  59. package/src/api/route.ts +7 -2
  60. package/src/client/client.test.ts +195 -0
  61. package/src/client/client.ts +185 -10
  62. package/src/client/vue.test.ts +253 -3
  63. package/src/client/vue.ts +44 -1
  64. package/src/internal/trace-context.ts +35 -0
  65. package/src/mod-client.ts +89 -9
  66. package/src/mod.ts +7 -1
  67. package/src/runtime.ts +48 -0
  68. package/src/test/test.ts +13 -4
  69. package/tsdown.config.ts +1 -0
@@ -24,12 +24,12 @@ export type LinkedFragmentCallback<
24
24
  TConfig,
25
25
  TOptions extends FragnoPublicConfig,
26
26
  TServiceDependencies,
27
+ TFragment extends AnyFragnoInstantiatedFragment = AnyFragnoInstantiatedFragment,
27
28
  > = (context: {
28
29
  config: TConfig;
29
30
  options: TOptions;
30
31
  serviceDependencies?: TServiceDependencies;
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- }) => FragnoInstantiatedFragment<any, any, any, any, any, any, any>;
32
+ }) => TFragment;
33
33
 
34
34
  /**
35
35
  * Extract the services type from a FragnoInstantiatedFragment
@@ -223,12 +223,27 @@ export interface FragmentDefinition<
223
223
  deps: TDeps;
224
224
  }) => RequestContextStorage<TRequestStorage>;
225
225
 
226
+ /**
227
+ * Optional factory for internal data attached to fragment.$internal.
228
+ */
229
+ internalDataFactory?: (context: {
230
+ config: TConfig;
231
+ options: TOptions;
232
+ deps: TDeps;
233
+ linkedFragments: TLinkedFragments;
234
+ }) => Record<string, unknown> | void;
235
+
226
236
  /**
227
237
  * Optional linked fragments that will be automatically instantiated with this fragment.
228
238
  * Linked fragments are service-only and share the same config/options as the parent.
229
239
  */
230
240
  linkedFragments?: {
231
- [K in keyof TLinkedFragments]: LinkedFragmentCallback<TConfig, TOptions, TServiceDependencies>;
241
+ [K in keyof TLinkedFragments]: LinkedFragmentCallback<
242
+ TConfig,
243
+ TOptions,
244
+ TServiceDependencies,
245
+ TLinkedFragments[K]
246
+ >;
232
247
  };
233
248
 
234
249
  $serviceThisContext?: TServiceThisContext;
@@ -307,7 +322,12 @@ export class FragmentDefinitionBuilder<
307
322
  deps: TDeps;
308
323
  }) => RequestContextStorage<TRequestStorage>;
309
324
  #linkedFragments?: {
310
- [K in keyof TLinkedFragments]: LinkedFragmentCallback<TConfig, TOptions, TServiceDependencies>;
325
+ [K in keyof TLinkedFragments]: LinkedFragmentCallback<
326
+ TConfig,
327
+ TOptions,
328
+ TServiceDependencies,
329
+ TLinkedFragments[K]
330
+ >;
311
331
  };
312
332
 
313
333
  constructor(
@@ -368,7 +388,8 @@ export class FragmentDefinitionBuilder<
368
388
  [K in keyof TLinkedFragments]: LinkedFragmentCallback<
369
389
  TConfig,
370
390
  TOptions,
371
- TServiceDependencies
391
+ TServiceDependencies,
392
+ TLinkedFragments[K]
372
393
  >;
373
394
  };
374
395
  },
@@ -966,15 +987,13 @@ export class FragmentDefinitionBuilder<
966
987
 
967
988
  /**
968
989
  * Register a linked fragment that will be automatically instantiated.
969
- * Linked fragments are service-only (no routes) and share the same config/options as the parent.
970
- * All services from the linked fragment will be available as private services.
990
+ * Linked fragments share the same config/options as the parent and their services
991
+ * are exposed as private services. Routes are not exposed by default, but the
992
+ * instantiator may mount internal linked fragment routes under an internal prefix.
971
993
  */
972
- withLinkedFragment<
973
- const TName extends string,
974
- TCallback extends LinkedFragmentCallback<TConfig, TOptions, TServiceDependencies>,
975
- >(
994
+ withLinkedFragment<const TName extends string, TFragment extends AnyFragnoInstantiatedFragment>(
976
995
  name: TName,
977
- callback: TCallback,
996
+ callback: LinkedFragmentCallback<TConfig, TOptions, TServiceDependencies, TFragment>,
978
997
  ): FragmentDefinitionBuilder<
979
998
  TConfig,
980
999
  TOptions,
@@ -982,18 +1001,18 @@ export class FragmentDefinitionBuilder<
982
1001
  TBaseServices,
983
1002
  TServices,
984
1003
  TServiceDependencies,
985
- TPrivateServices & ExtractLinkedServices<TCallback>,
1004
+ TPrivateServices & ExtractLinkedServices<() => TFragment>,
986
1005
  TServiceThisContext,
987
1006
  THandlerThisContext,
988
1007
  TRequestStorage,
989
- TLinkedFragments & { [K in TName]: ReturnType<TCallback> }
1008
+ TLinkedFragments & { [K in TName]: TFragment }
990
1009
  > {
991
1010
  const newLinkedFragments = {
992
1011
  ...this.#linkedFragments,
993
1012
  [name]: callback,
994
1013
  };
995
1014
 
996
- // Cast is safe: We're declaring that the returned builder has TPrivateServices & ExtractLinkedServices<TCallback>,
1015
+ // Cast is safe: We're declaring that the returned builder has TPrivateServices & ExtractLinkedServices<TFragment>,
997
1016
  // even though the runtime privateServices hasn't changed yet. The linked fragment services will be
998
1017
  // merged into privateServices at instantiation time by the instantiator.
999
1018
  return new FragmentDefinitionBuilder(this.#name, {
@@ -1013,11 +1032,11 @@ export class FragmentDefinitionBuilder<
1013
1032
  TBaseServices,
1014
1033
  TServices,
1015
1034
  TServiceDependencies,
1016
- TPrivateServices & ExtractLinkedServices<TCallback>,
1035
+ TPrivateServices & ExtractLinkedServices<() => TFragment>,
1017
1036
  TServiceThisContext,
1018
1037
  THandlerThisContext,
1019
1038
  TRequestStorage,
1020
- TLinkedFragments & { [K in TName]: ReturnType<TCallback> }
1039
+ TLinkedFragments & { [K in TName]: TFragment }
1021
1040
  >;
1022
1041
  }
1023
1042
 
@@ -1,6 +1,10 @@
1
1
  import { describe, it, expect, vi, expectTypeOf } from "vitest";
2
2
  import { defineFragment } from "./fragment-definition-builder";
3
- import { instantiate, FragnoInstantiatedFragment } from "./fragment-instantiator";
3
+ import {
4
+ instantiate,
5
+ instantiateFragment,
6
+ FragnoInstantiatedFragment,
7
+ } from "./fragment-instantiator";
4
8
  import { defineRoute, defineRoutes, type AnyFragmentDefinition } from "./route";
5
9
  import type { FragnoPublicConfig } from "./shared-types";
6
10
  import type { RequestThisContext } from "./api";
@@ -412,6 +416,56 @@ describe("fragment-instantiator", () => {
412
416
  expect(data).toEqual({ userId: "123" });
413
417
  });
414
418
 
419
+ it("should URL decode path params", async () => {
420
+ const definition = defineFragment("test-fragment").build();
421
+
422
+ const route = defineRoute({
423
+ method: "GET",
424
+ path: "/users/:name",
425
+ handler: async (input, { json }) => {
426
+ return json({ userName: input.pathParams.name });
427
+ },
428
+ });
429
+
430
+ const fragment = instantiate(definition)
431
+ .withRoutes([route])
432
+ .withOptions({ mountRoute: "/api" })
433
+ .build();
434
+
435
+ // URL with encoded space: "a%20b" should be decoded to "a b"
436
+ const request = new Request("http://localhost/api/users/a%20b");
437
+ const response = await fragment.handler(request);
438
+
439
+ expect(response.status).toBe(200);
440
+ const data = await response.json();
441
+ expect(data).toEqual({ userName: "a b" });
442
+ });
443
+
444
+ it("should URL decode path params with special characters", async () => {
445
+ const definition = defineFragment("test-fragment").build();
446
+
447
+ const route = defineRoute({
448
+ method: "GET",
449
+ path: "/files/:path",
450
+ handler: async (input, { json }) => {
451
+ return json({ filePath: input.pathParams.path });
452
+ },
453
+ });
454
+
455
+ const fragment = instantiate(definition)
456
+ .withRoutes([route])
457
+ .withOptions({ mountRoute: "/api" })
458
+ .build();
459
+
460
+ // URL with encoded slash: "folder%2Fsubfolder" should be decoded to "folder/subfolder"
461
+ const request = new Request("http://localhost/api/files/folder%2Fsubfolder");
462
+ const response = await fragment.handler(request);
463
+
464
+ expect(response.status).toBe(200);
465
+ const data = await response.json();
466
+ expect(data).toEqual({ filePath: "folder/subfolder" });
467
+ });
468
+
415
469
  it("should handle POST requests with body", async () => {
416
470
  const definition = defineFragment("test-fragment").build();
417
471
 
@@ -446,6 +500,170 @@ describe("fragment-instantiator", () => {
446
500
  const data = await response.json();
447
501
  expect(data).toEqual({ received: { test: "data" } });
448
502
  });
503
+
504
+ it("should accept FormData for routes with contentType: multipart/form-data", async () => {
505
+ const definition = defineFragment("test-fragment").build();
506
+
507
+ const route = defineRoute({
508
+ method: "POST",
509
+ path: "/upload",
510
+ contentType: "multipart/form-data",
511
+ handler: async (ctx, { json }) => {
512
+ const formData = ctx.formData();
513
+ const description = formData.get("description") as string;
514
+ return json({ description });
515
+ },
516
+ });
517
+
518
+ const fragment = instantiate(definition)
519
+ .withRoutes([route])
520
+ .withOptions({ mountRoute: "/api" })
521
+ .build();
522
+
523
+ const formData = new FormData();
524
+ formData.append("description", "Test file upload");
525
+
526
+ const request = new Request("http://localhost/api/upload", {
527
+ method: "POST",
528
+ body: formData,
529
+ });
530
+
531
+ const response = await fragment.handler(request);
532
+
533
+ expect(response.status).toBe(200);
534
+ const data = await response.json();
535
+ expect(data).toEqual({ description: "Test file upload" });
536
+ });
537
+
538
+ it("should reject FormData for JSON routes (default contentType)", async () => {
539
+ const definition = defineFragment("test-fragment").build();
540
+
541
+ const route = defineRoute({
542
+ method: "POST",
543
+ path: "/json-only",
544
+ inputSchema: z.object({ name: z.string() }),
545
+ handler: async ({ input }, { json }) => {
546
+ const body = await input.valid();
547
+ return json(body);
548
+ },
549
+ });
550
+
551
+ const fragment = instantiate(definition)
552
+ .withRoutes([route])
553
+ .withOptions({ mountRoute: "/api" })
554
+ .build();
555
+
556
+ const formData = new FormData();
557
+ formData.append("name", "test");
558
+
559
+ const request = new Request("http://localhost/api/json-only", {
560
+ method: "POST",
561
+ body: formData,
562
+ });
563
+
564
+ const response = await fragment.handler(request);
565
+
566
+ expect(response.status).toBe(415);
567
+ const data = await response.json();
568
+ expect(data.code).toBe("UNSUPPORTED_MEDIA_TYPE");
569
+ });
570
+
571
+ it("should reject JSON for FormData routes", async () => {
572
+ const definition = defineFragment("test-fragment").build();
573
+
574
+ const route = defineRoute({
575
+ method: "POST",
576
+ path: "/upload",
577
+ contentType: "multipart/form-data",
578
+ handler: async (ctx, { json }) => {
579
+ // Verify formData() works (would throw if not FormData)
580
+ ctx.formData();
581
+ return json({ success: true });
582
+ },
583
+ });
584
+
585
+ const fragment = instantiate(definition)
586
+ .withRoutes([route])
587
+ .withOptions({ mountRoute: "/api" })
588
+ .build();
589
+
590
+ const request = new Request("http://localhost/api/upload", {
591
+ method: "POST",
592
+ headers: { "Content-Type": "application/json" },
593
+ body: JSON.stringify({ name: "test" }),
594
+ });
595
+
596
+ const response = await fragment.handler(request);
597
+
598
+ expect(response.status).toBe(415);
599
+ const data = await response.json();
600
+ expect(data.code).toBe("UNSUPPORTED_MEDIA_TYPE");
601
+ });
602
+
603
+ it("should accept octet-stream for routes with contentType: application/octet-stream", async () => {
604
+ const definition = defineFragment("test-fragment").build();
605
+
606
+ const route = defineRoute({
607
+ method: "PUT",
608
+ path: "/stream",
609
+ contentType: "application/octet-stream",
610
+ handler: async (ctx, { json }) => {
611
+ const stream = ctx.bodyStream();
612
+ const text = await new Response(stream).text();
613
+ return json({ received: text });
614
+ },
615
+ });
616
+
617
+ const fragment = instantiate(definition)
618
+ .withRoutes([route])
619
+ .withOptions({ mountRoute: "/api" })
620
+ .build();
621
+
622
+ const request = new Request("http://localhost/api/stream", {
623
+ method: "PUT",
624
+ headers: { "Content-Type": "application/octet-stream" },
625
+ body: new TextEncoder().encode("hello"),
626
+ });
627
+
628
+ const response = await fragment.handler(request);
629
+
630
+ expect(response.status).toBe(200);
631
+ const data = await response.json();
632
+ expect(data).toEqual({ received: "hello" });
633
+ });
634
+
635
+ it("should reject JSON for octet-stream routes", async () => {
636
+ const definition = defineFragment("test-fragment").build();
637
+
638
+ const route = defineRoute({
639
+ method: "PUT",
640
+ path: "/stream",
641
+ contentType: "application/octet-stream",
642
+ handler: async (ctx, { json }) => {
643
+ if (!ctx.isBodyStream()) {
644
+ throw new Error("Expected stream body");
645
+ }
646
+ return json({ ok: true });
647
+ },
648
+ });
649
+
650
+ const fragment = instantiate(definition)
651
+ .withRoutes([route])
652
+ .withOptions({ mountRoute: "/api" })
653
+ .build();
654
+
655
+ const request = new Request("http://localhost/api/stream", {
656
+ method: "PUT",
657
+ headers: { "Content-Type": "application/json" },
658
+ body: JSON.stringify({ hello: "world" }),
659
+ });
660
+
661
+ const response = await fragment.handler(request);
662
+
663
+ expect(response.status).toBe(415);
664
+ const data = await response.json();
665
+ expect(data.code).toBe("UNSUPPORTED_MEDIA_TYPE");
666
+ });
449
667
  });
450
668
 
451
669
  describe("callRoute", () => {
@@ -1281,6 +1499,78 @@ describe("fragment-instantiator", () => {
1281
1499
  expect(linkedFragment?.name).toBe("linked-fragment");
1282
1500
  });
1283
1501
 
1502
+ it("should mount internal linked fragment routes under /_internal", async () => {
1503
+ const linkedFragmentDef = defineFragment("linked-fragment").build();
1504
+ const linkedRoutes = defineRoutes(linkedFragmentDef).create(({ defineRoute }) => [
1505
+ defineRoute({
1506
+ method: "GET",
1507
+ path: "/status",
1508
+ handler: async (_input, { json }) => {
1509
+ return json({ ok: true });
1510
+ },
1511
+ }),
1512
+ ]);
1513
+
1514
+ const definition = defineFragment("main-fragment")
1515
+ .withLinkedFragment("_fragno_internal", ({ config, options }) => {
1516
+ return instantiate(linkedFragmentDef)
1517
+ .withConfig(config)
1518
+ .withOptions(options)
1519
+ .withRoutes([linkedRoutes])
1520
+ .build();
1521
+ })
1522
+ .build();
1523
+
1524
+ const fragment = instantiate(definition).withOptions({}).build();
1525
+
1526
+ const response = await fragment.callRouteRaw("GET", "/_internal/status" as never);
1527
+ expect(response.status).toBe(200);
1528
+ await expect(response.json()).resolves.toEqual({ ok: true });
1529
+ });
1530
+
1531
+ it("should run middleware set on the internal fragment", async () => {
1532
+ const linkedFragmentDef = defineFragment("linked-fragment").build();
1533
+ const linkedRoutes = defineRoutes(linkedFragmentDef).create(({ defineRoute }) => [
1534
+ defineRoute({
1535
+ method: "GET",
1536
+ path: "/status",
1537
+ handler: async (_input, { json }) => {
1538
+ return json({ ok: true });
1539
+ },
1540
+ }),
1541
+ ]);
1542
+
1543
+ const definition = defineFragment("main-fragment")
1544
+ .withLinkedFragment("_fragno_internal", ({ config, options }) => {
1545
+ return instantiate(linkedFragmentDef)
1546
+ .withConfig(config)
1547
+ .withOptions(options)
1548
+ .withRoutes([linkedRoutes])
1549
+ .build();
1550
+ })
1551
+ .build();
1552
+
1553
+ const fragment = instantiate(definition).withOptions({ mountRoute: "/api" }).build();
1554
+
1555
+ const internalFragment = fragment.$internal.linkedFragments._fragno_internal;
1556
+ internalFragment.withMiddleware(async ({ ifMatchesRoute }) => {
1557
+ const result = await ifMatchesRoute("GET", "/status", async (_input, { json }) => {
1558
+ return json({ ok: false, source: "internal-middleware" }, 418);
1559
+ });
1560
+
1561
+ return result;
1562
+ });
1563
+
1564
+ const response = await fragment.handler(
1565
+ new Request("http://localhost/api/_internal/status", {
1566
+ method: "GET",
1567
+ }),
1568
+ );
1569
+
1570
+ expect(response.status).toBe(418);
1571
+ expect(await response.json()).toEqual({ ok: false, source: "internal-middleware" });
1572
+ });
1573
+
1284
1574
  it("should pass config and options to linked fragments", () => {
1285
1575
  interface Config {
1286
1576
  value: string;
@@ -1485,4 +1775,142 @@ describe("fragment-instantiator", () => {
1485
1775
  expect(data.code).toBe("INTERNAL_SERVER_ERROR");
1486
1776
  });
1487
1777
  });
1778
+
1779
+ describe("dry run mode", () => {
1780
+ it("should handle dependency errors in dry run mode", () => {
1781
+ interface Config {
1782
+ requiredKey: string;
1783
+ }
1784
+
1785
+ const definition = defineFragment<Config>("test-fragment")
1786
+ .withDependencies(({ config }) => {
1787
+ if (!config.requiredKey) {
1788
+ throw new Error("Missing required key");
1789
+ }
1790
+ return { key: config.requiredKey };
1791
+ })
1792
+ .build();
1793
+
1794
+ // Without dry run - should throw
1795
+ expect(() => {
1796
+ instantiateFragment(definition, {} as Config, [], {});
1797
+ }).toThrow("Missing required key");
1798
+
1799
+ // With dry run - should succeed with stub deps
1800
+ const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
1801
+ dryRun: true,
1802
+ });
1803
+
1804
+ expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
1805
+ expect(fragment.$internal.deps).toEqual({});
1806
+ });
1807
+
1808
+ it("should handle service errors in dry run mode", () => {
1809
+ interface Config {
1810
+ value: string;
1811
+ }
1812
+
1813
+ const definition = defineFragment<Config>("test-fragment")
1814
+ .withDependencies(({ config }) => {
1815
+ if (!config.value) {
1816
+ throw new Error("Missing value");
1817
+ }
1818
+ return { val: config.value };
1819
+ })
1820
+ .providesBaseService(({ deps }) => {
1821
+ if (!deps.val) {
1822
+ throw new Error("Missing deps.val");
1823
+ }
1824
+ return {
1825
+ getValue: () => deps.val,
1826
+ };
1827
+ })
1828
+ .build();
1829
+
1830
+ // Without dry run - should throw during deps initialization
1831
+ expect(() => {
1832
+ instantiateFragment(definition, {} as Config, [], {});
1833
+ }).toThrow("Missing value");
1834
+
1835
+ // With dry run - should succeed with stub services
1836
+ const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
1837
+ dryRun: true,
1838
+ });
1839
+
1840
+ expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
1841
+ // Services should be empty objects in dry run
1842
+ expect(fragment.services).toBeDefined();
1843
+ });
1844
+
1845
+ it("should handle named service errors in dry run mode", () => {
1846
+ interface Config {
1847
+ apiKey: string;
1848
+ }
1849
+
1850
+ const definition = defineFragment<Config>("test-fragment")
1851
+ .withDependencies(({ config }) => {
1852
+ if (!config.apiKey) {
1853
+ throw new Error("Missing API key");
1854
+ }
1855
+ return { key: config.apiKey };
1856
+ })
1857
+ .providesService("apiService", ({ deps }) => {
1858
+ if (!deps.key) {
1859
+ throw new Error("Cannot create service without key");
1860
+ }
1861
+ return {
1862
+ call: () => `Calling with ${deps.key}`,
1863
+ };
1864
+ })
1865
+ .build();
1866
+
1867
+ const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
1868
+ dryRun: true,
1869
+ });
1870
+
1871
+ expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
1872
+ expect(fragment.services.apiService).toBeDefined();
1873
+ expect(fragment.services.apiService).toEqual({});
1874
+ });
1875
+
1876
+ it("should handle private service errors in dry run mode", () => {
1877
+ interface Config {
1878
+ secret: string;
1879
+ }
1880
+
1881
+ const definition = defineFragment<Config>("test-fragment")
1882
+ .withDependencies(({ config }) => {
1883
+ if (!config.secret) {
1884
+ throw new Error("Missing secret");
1885
+ }
1886
+ return { secret: config.secret };
1887
+ })
1888
+ .build();
1889
+
1890
+ const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
1891
+ dryRun: true,
1892
+ });
1893
+
1894
+ expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
1895
+ });
1896
+
1897
+ it("should not catch errors when dry run is disabled", () => {
1898
+ interface Config {
1899
+ key: string;
1900
+ }
1901
+
1902
+ const definition = defineFragment<Config>("test-fragment")
1903
+ .withDependencies(({ config }) => {
1904
+ if (!config.key) {
1905
+ throw new Error("Missing key");
1906
+ }
1907
+ return { key: config.key };
1908
+ })
1909
+ .build();
1910
+
1911
+ expect(() => {
1912
+ instantiateFragment(definition, {} as Config, [], {}, undefined, { dryRun: false });
1913
+ }).toThrow("Missing key");
1914
+ });
1915
+ });
1488
1916
  });