@gradio/core 1.4.0 → 1.4.2

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.
package/package.json CHANGED
@@ -1,67 +1,67 @@
1
1
  {
2
2
  "name": "@gradio/core",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "devDependencies": {
6
- "@gradio/accordion": "^0.5.32",
7
- "@gradio/annotatedimage": "^0.11.4",
8
- "@gradio/audio": "^0.22.4",
9
- "@gradio/atoms": "^0.22.2",
10
- "@gradio/box": "^0.2.30",
11
- "@gradio/button": "^0.6.5",
6
+ "@gradio/accordion": "^0.5.34",
7
+ "@gradio/annotatedimage": "^0.11.6",
8
+ "@gradio/atoms": "^0.23.0",
9
+ "@gradio/audio": "^0.23.1",
10
+ "@gradio/box": "^0.2.31",
12
11
  "@gradio/browserstate": "^0.3.7",
13
- "@gradio/chatbot": "^0.29.5",
14
- "@gradio/checkbox": "^0.6.4",
15
- "@gradio/checkboxgroup": "^0.9.4",
12
+ "@gradio/button": "^0.6.6",
13
+ "@gradio/checkbox": "^0.6.6",
14
+ "@gradio/chatbot": "^0.29.7",
15
+ "@gradio/checkboxgroup": "^0.10.1",
16
16
  "@gradio/client": "^2.1.0",
17
- "@gradio/code": "^0.17.4",
18
- "@gradio/colorpicker": "^0.5.7",
17
+ "@gradio/code": "^0.17.6",
18
+ "@gradio/colorpicker": "^0.5.9",
19
19
  "@gradio/column": "^0.3.2",
20
- "@gradio/datetime": "^0.4.4",
21
- "@gradio/dataframe": "^0.21.7",
20
+ "@gradio/dataframe": "^0.23.0",
21
+ "@gradio/dataset": "^0.5.7",
22
22
  "@gradio/downloadbutton": "^0.4.18",
23
- "@gradio/dataset": "^0.5.5",
24
- "@gradio/dropdown": "^0.11.5",
25
- "@gradio/file": "^0.14.4",
26
- "@gradio/fallback": "^0.4.35",
27
- "@gradio/fileexplorer": "^0.6.4",
28
- "@gradio/gallery": "^0.17.3",
29
- "@gradio/form": "^0.3.1",
30
- "@gradio/group": "^0.3.3",
31
- "@gradio/html": "^0.11.1",
32
- "@gradio/highlightedtext": "^0.11.3",
23
+ "@gradio/dropdown": "^0.11.7",
24
+ "@gradio/fallback": "^0.4.37",
25
+ "@gradio/datetime": "^0.4.6",
26
+ "@gradio/file": "^0.14.6",
27
+ "@gradio/fileexplorer": "^0.6.6",
28
+ "@gradio/form": "^0.3.2",
29
+ "@gradio/highlightedtext": "^0.11.5",
30
+ "@gradio/group": "^0.3.4",
31
+ "@gradio/gallery": "^0.17.5",
32
+ "@gradio/html": "^0.12.1",
33
33
  "@gradio/icons": "^0.15.1",
34
- "@gradio/image": "^0.25.4",
35
- "@gradio/imageeditor": "^0.18.7",
36
- "@gradio/imageslider": "^0.4.4",
37
- "@gradio/json": "^0.7.3",
38
- "@gradio/label": "^0.6.4",
39
- "@gradio/markdown": "^0.13.29",
40
- "@gradio/multimodaltextbox": "^0.11.7",
41
- "@gradio/number": "^0.8.4",
42
- "@gradio/nativeplot": "^0.10.3",
43
- "@gradio/paramviewer": "^0.9.5",
44
- "@gradio/model3d": "^0.16.5",
45
- "@gradio/plot": "^0.10.5",
46
- "@gradio/sidebar": "^0.2.4",
47
- "@gradio/radio": "^0.9.4",
48
- "@gradio/simpledropdown": "^0.3.35",
49
- "@gradio/simpleimage": "^0.9.6",
50
- "@gradio/simpletextbox": "^0.3.37",
34
+ "@gradio/image": "^0.26.1",
35
+ "@gradio/imageeditor": "^0.18.9",
36
+ "@gradio/imageslider": "^0.4.6",
37
+ "@gradio/label": "^0.6.6",
38
+ "@gradio/json": "^0.7.5",
39
+ "@gradio/model3d": "^0.16.7",
40
+ "@gradio/markdown": "^0.13.31",
41
+ "@gradio/multimodaltextbox": "^0.11.9",
42
+ "@gradio/number": "^0.8.6",
43
+ "@gradio/nativeplot": "^0.10.5",
44
+ "@gradio/paramviewer": "^0.9.7",
45
+ "@gradio/plot": "^0.10.7",
46
+ "@gradio/radio": "^0.10.1",
51
47
  "@gradio/row": "^0.3.1",
52
- "@gradio/state": "^0.2.3",
53
- "@gradio/slider": "^0.7.7",
48
+ "@gradio/simpledropdown": "^0.3.37",
49
+ "@gradio/sidebar": "^0.2.6",
50
+ "@gradio/simpleimage": "^0.9.8",
51
+ "@gradio/simpletextbox": "^0.3.39",
52
+ "@gradio/slider": "^0.7.9",
53
+ "@gradio/statustracker": "^0.13.1",
54
54
  "@gradio/tabitem": "^0.6.6",
55
- "@gradio/statustracker": "^0.12.5",
56
- "@gradio/textbox": "^0.13.5",
57
- "@gradio/tabs": "^0.5.8",
55
+ "@gradio/state": "^0.2.3",
56
+ "@gradio/tabs": "^0.5.9",
57
+ "@gradio/textbox": "^0.13.7",
58
58
  "@gradio/theme": "^0.6.1",
59
+ "@gradio/upload": "^0.17.8",
59
60
  "@gradio/timer": "^0.4.9",
60
- "@gradio/upload": "^0.17.7",
61
- "@gradio/utils": "^0.12.0",
61
+ "@gradio/utils": "^0.12.2",
62
62
  "@gradio/uploadbutton": "^0.9.18",
63
- "@gradio/video": "^0.20.4",
64
- "@gradio/vibeeditor": "^0.3.6"
63
+ "@gradio/vibeeditor": "^0.3.8",
64
+ "@gradio/video": "^0.20.6"
65
65
  },
66
66
  "msw": {
67
67
  "workerDirectory": "public"
@@ -17,7 +17,10 @@ export function formatter(value: string | null | undefined): string {
17
17
  }
18
18
 
19
19
  const direct_translation = translate(string_value);
20
- if (direct_translation !== string_value) {
20
+ if (
21
+ typeof direct_translation === "string" &&
22
+ direct_translation !== string_value
23
+ ) {
21
24
  return direct_translation;
22
25
  }
23
26
 
package/src/i18n.test.ts CHANGED
@@ -8,8 +8,31 @@ import {
8
8
  afterEach
9
9
  } from "vitest";
10
10
  import { Lang, process_langs } from "./i18n";
11
- import languagesByAnyCode from "wikidata-lang/indexes/by_any_code";
11
+ // wikidata-lang/indexes/by_any_code uses Node.js createRequire internally.
12
+ // Import the raw JSON data and build the index directly for browser compatibility.
13
+ import languages from "wikidata-lang/data/languages.json";
12
14
  import BCP47 from "./lang/BCP47_codes";
15
+
16
+ const languagesByAnyCode: Record<string, any[]> = {};
17
+ for (const langData of languages) {
18
+ for (const codeName of [
19
+ "wmCode",
20
+ "iso6391",
21
+ "iso6392",
22
+ "iso6393",
23
+ "iso6396"
24
+ ]) {
25
+ const codes = (langData as Record<string, any>)[codeName];
26
+ if (!codes) continue;
27
+ for (const code of codes) {
28
+ if (languagesByAnyCode[code] == null) {
29
+ languagesByAnyCode[code] = [langData];
30
+ } else if (!languagesByAnyCode[code].includes(langData)) {
31
+ languagesByAnyCode[code].push(langData);
32
+ }
33
+ }
34
+ }
35
+ }
13
36
  import {
14
37
  get_initial_locale,
15
38
  load_translations,
@@ -25,7 +48,10 @@ vi.mock("svelte-i18n", () => ({
25
48
  locale: { set: vi.fn() },
26
49
  _: vi.fn((key) => `translated_${key}`),
27
50
  addMessages: vi.fn(),
28
- init: vi.fn().mockResolvedValue(undefined)
51
+ init: vi.fn().mockResolvedValue(undefined),
52
+ getLocaleFromNavigator: vi.fn(() => "en"),
53
+ register: vi.fn(),
54
+ waitLocale: vi.fn().mockResolvedValue(undefined)
29
55
  }));
30
56
 
31
57
  const mock_translations: Record<string, string> = {
@@ -40,6 +40,20 @@ const type_map = {
40
40
  walkthrough: "tabs",
41
41
  walkthroughstep: "tabitem"
42
42
  };
43
+
44
+ export function get_api_url(config: Omit<AppConfig, "api_url">): string {
45
+ // Handle api_prefix correctly when app is mounted at a subpath.
46
+ // config.root may not include a trailing slash, so we normalize its pathname
47
+ // before appending api_prefix to ensure correct URL construction.
48
+ const rootUrl = new URL(config.root);
49
+ const rootPath = rootUrl.pathname.endsWith("/")
50
+ ? rootUrl.pathname
51
+ : rootUrl.pathname + "/";
52
+ const apiPrefix = config.api_prefix.startsWith("/")
53
+ ? config.api_prefix
54
+ : "/" + config.api_prefix;
55
+ return new URL(rootPath.slice(0, -1) + apiPrefix, rootUrl.origin).toString();
56
+ }
43
57
  export class AppTree {
44
58
  /** the raw component structure received from the backend */
45
59
  #component_payload: ComponentMeta[];
@@ -91,9 +105,10 @@ export class AppTree {
91
105
  this.ready_resolve = resolve;
92
106
  });
93
107
  this.reactive_formatter = reactive_formatter;
108
+ const api_url = get_api_url(config);
94
109
  this.#config = {
95
110
  ...config,
96
- api_url: new URL(config.api_prefix, config.root).toString()
111
+ api_url
97
112
  };
98
113
  this.#component_payload = components;
99
114
  this.#layout_payload = layout;
@@ -144,9 +159,10 @@ export class AppTree {
144
159
  ) {
145
160
  this.#layout_payload = layout;
146
161
  this.#component_payload = components;
162
+ const api_url = get_api_url(config);
147
163
  this.#config = {
148
164
  ...config,
149
- api_url: new URL(config.api_prefix, config.root).toString()
165
+ api_url
150
166
  };
151
167
  this.#dependency_payload = dependencies;
152
168
 
@@ -421,11 +437,6 @@ export class AppTree {
421
437
  new_state: Partial<SharedProps> & Record<string, unknown>,
422
438
  check_visibility: boolean = true
423
439
  ) {
424
- // Visibility is tricky 😅
425
- // If the component is not visible, it has not been rendered
426
- // and so it has no _set_data callback
427
- // Therefore, we need to traverse the tree and set the visible prop to true
428
- // and then render it and its children. After that, we can call the _set_data callback
429
440
  const node = find_node_by_id(this.root!, id);
430
441
  let already_updated_visibility = false;
431
442
  if (check_visibility && !node?.component) {
@@ -465,6 +476,12 @@ export class AppTree {
465
476
  if ("value" in new_state && !dequal(old_value, new_state.value)) {
466
477
  this.#event_dispatcher(id, "change", null);
467
478
  }
479
+
480
+ // If this is a non-mounted tabitem, update the parent Tabs'
481
+ // initial_tabs so the tab button reflects the new state.
482
+ if (node?.type === "tabitem") {
483
+ this.#update_parent_tabs_initial_tab(id, node);
484
+ }
468
485
  } else if (_set_data) {
469
486
  _set_data(new_state);
470
487
  }
@@ -492,6 +509,55 @@ export class AppTree {
492
509
  });
493
510
  }
494
511
 
512
+ /**
513
+ * Updates the parent Tabs component's initial_tabs when a non-mounted
514
+ * tabitem's props change. This ensures the tab button (rendered by
515
+ * the Tabs component) reflects the updated state even though the
516
+ * TabItem component itself is not mounted.
517
+ */
518
+ #update_parent_tabs_initial_tab(
519
+ id: number,
520
+ node: ProcessedComponentMeta
521
+ ): void {
522
+ const parent = find_parent(this.root!, id);
523
+ if (!parent || parent.type !== "tabs") return;
524
+
525
+ const initial_tabs = parent.props.props.initial_tabs as Tab[];
526
+ if (!initial_tabs) return;
527
+
528
+ const tab_index = initial_tabs.findIndex((t) => t.component_id === node.id);
529
+ if (tab_index === -1) return;
530
+
531
+ const i18n = node.props.props.i18n as ((str: string) => string) | undefined;
532
+ const raw_label = node.props.shared_props.label as string;
533
+ // Use original_visibility since the node's visible may have been
534
+ // set to false by the startup optimization for non-selected tabs.
535
+ const visible =
536
+ "original_visibility" in node
537
+ ? (node.original_visibility as boolean)
538
+ : (node.props.shared_props.visible as boolean);
539
+ initial_tabs[tab_index] = {
540
+ label: i18n ? i18n(raw_label) : raw_label,
541
+ id: node.props.props.id as string,
542
+ elem_id: node.props.shared_props.elem_id,
543
+ visible,
544
+ interactive: node.props.shared_props.interactive,
545
+ scale: node.props.shared_props.scale || null,
546
+ component_id: node.id
547
+ };
548
+
549
+ // Trigger reactivity by replacing the array
550
+ parent.props.props.initial_tabs = [...initial_tabs];
551
+
552
+ // Also update via _set_data if the Tabs component is mounted
553
+ const parent_set_data = this.#set_callbacks.get(parent.id);
554
+ if (parent_set_data) {
555
+ parent_set_data({
556
+ initial_tabs: parent.props.props.initial_tabs
557
+ });
558
+ }
559
+ }
560
+
495
561
  /**
496
562
  * Gets the current state of a component by its ID
497
563
  * @param id the ID of the component to get the state of
@@ -671,8 +737,9 @@ function gather_props(
671
737
 
672
738
  _shared_props.load_component = (
673
739
  name: string,
674
- variant: "base" | "component" | "example"
675
- ) => get_component(name, "", api_url, variant).component as LoadingComponent;
740
+ variant: "base" | "component" | "example",
741
+ component_class_id?: string
742
+ ) => get_component(name, component_class_id || "", api_url, variant);
676
743
 
677
744
  _shared_props.visible =
678
745
  _shared_props.visible === undefined ? true : _shared_props.visible;
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, vi } from "vitest";
2
2
  import { spy } from "tinyspy";
3
- import { setupServer } from "msw/node";
3
+ import { setupWorker } from "msw/browser";
4
4
  import { http, HttpResponse } from "msw";
5
5
  import type { client_return } from "@gradio/client";
6
6
  import { Dependency, TargetMap } from "./types";
@@ -11,6 +11,9 @@ import {
11
11
  process_server_fn,
12
12
  get_component
13
13
  } from "./_init";
14
+ import { get_api_url } from "./init.svelte";
15
+ import type { AppConfig } from "./types";
16
+ import { commands } from "@vitest/browser/context";
14
17
 
15
18
  describe("process_frontend_fn", () => {
16
19
  test("empty source code returns null", () => {
@@ -461,6 +464,8 @@ describe("get_component", () => {
461
464
  value: "hi",
462
465
  interactive: false
463
466
  },
467
+ key: "test-component-one",
468
+
464
469
  has_modes: false,
465
470
  instance: {} as any,
466
471
  component: {} as any
@@ -512,13 +517,307 @@ describe("get_component", () => {
512
517
  }
513
518
  );
514
519
 
515
- const server = setupServer(...handlers);
516
- server.listen();
520
+ const worker = setupWorker(...handlers);
521
+ worker.start();
517
522
 
518
523
  await get_component("test-random", id, api_url, []).component;
519
524
 
520
525
  expect(mock).toHaveBeenCalled();
521
526
 
522
- server.close();
527
+ worker.stop();
528
+ });
529
+ });
530
+
531
+ describe("get_api_url", () => {
532
+ describe("root URL with trailing slash", () => {
533
+ test("root with trailing slash, api_prefix with leading slash", () => {
534
+ const config: Omit<AppConfig, "api_url"> = {
535
+ root: "http://example.com/myapp/",
536
+ api_prefix: "/api",
537
+ theme: "default",
538
+ version: "1.0.0",
539
+ autoscroll: true
540
+ };
541
+ const result = get_api_url(config);
542
+ expect(result).toBe("http://example.com/myapp/api");
543
+ });
544
+
545
+ test("root with trailing slash, api_prefix without leading slash", () => {
546
+ const config: Omit<AppConfig, "api_url"> = {
547
+ root: "http://example.com/myapp/",
548
+ api_prefix: "api",
549
+ theme: "default",
550
+ version: "1.0.0",
551
+ autoscroll: true
552
+ };
553
+ const result = get_api_url(config);
554
+ expect(result).toBe("http://example.com/myapp/api");
555
+ });
556
+
557
+ test("root at domain root with trailing slash", () => {
558
+ const config: Omit<AppConfig, "api_url"> = {
559
+ root: "http://example.com/",
560
+ api_prefix: "/api",
561
+ theme: "default",
562
+ version: "1.0.0",
563
+ autoscroll: true
564
+ };
565
+ const result = get_api_url(config);
566
+ expect(result).toBe("http://example.com/api");
567
+ });
568
+ });
569
+
570
+ describe("root URL without trailing slash", () => {
571
+ test("root without trailing slash, api_prefix with leading slash", () => {
572
+ const config: Omit<AppConfig, "api_url"> = {
573
+ root: "http://example.com/myapp",
574
+ api_prefix: "/api",
575
+ theme: "default",
576
+ version: "1.0.0",
577
+ autoscroll: true
578
+ };
579
+ const result = get_api_url(config);
580
+ expect(result).toBe("http://example.com/myapp/api");
581
+ });
582
+
583
+ test("root without trailing slash, api_prefix without leading slash", () => {
584
+ const config: Omit<AppConfig, "api_url"> = {
585
+ root: "http://example.com/myapp",
586
+ api_prefix: "api",
587
+ theme: "default",
588
+ version: "1.0.0",
589
+ autoscroll: true
590
+ };
591
+ const result = get_api_url(config);
592
+ expect(result).toBe("http://example.com/myapp/api");
593
+ });
594
+
595
+ test("root at domain root without trailing slash", () => {
596
+ const config: Omit<AppConfig, "api_url"> = {
597
+ root: "http://example.com",
598
+ api_prefix: "/api",
599
+ theme: "default",
600
+ version: "1.0.0",
601
+ autoscroll: true
602
+ };
603
+ const result = get_api_url(config);
604
+ expect(result).toBe("http://example.com/api");
605
+ });
606
+ });
607
+
608
+ describe("different root path combinations", () => {
609
+ test("root path is just '/'", () => {
610
+ const config: Omit<AppConfig, "api_url"> = {
611
+ root: "http://example.com/",
612
+ api_prefix: "/api",
613
+ theme: "default",
614
+ version: "1.0.0",
615
+ autoscroll: true
616
+ };
617
+ const result = get_api_url(config);
618
+ expect(result).toBe("http://example.com/api");
619
+ });
620
+
621
+ test("root path is '/' without trailing slash", () => {
622
+ const config: Omit<AppConfig, "api_url"> = {
623
+ root: "http://example.com",
624
+ api_prefix: "/api",
625
+ theme: "default",
626
+ version: "1.0.0",
627
+ autoscroll: true
628
+ };
629
+ const result = get_api_url(config);
630
+ expect(result).toBe("http://example.com/api");
631
+ });
632
+
633
+ test("root path is '/myapp'", () => {
634
+ const config: Omit<AppConfig, "api_url"> = {
635
+ root: "http://example.com/myapp",
636
+ api_prefix: "/api",
637
+ theme: "default",
638
+ version: "1.0.0",
639
+ autoscroll: true
640
+ };
641
+ const result = get_api_url(config);
642
+ expect(result).toBe("http://example.com/myapp/api");
643
+ });
644
+
645
+ test("root path is '/myapp/'", () => {
646
+ const config: Omit<AppConfig, "api_url"> = {
647
+ root: "http://example.com/myapp/",
648
+ api_prefix: "/api",
649
+ theme: "default",
650
+ version: "1.0.0",
651
+ autoscroll: true
652
+ };
653
+ const result = get_api_url(config);
654
+ expect(result).toBe("http://example.com/myapp/api");
655
+ });
656
+
657
+ test("root path is '/deep/nested/path'", () => {
658
+ const config: Omit<AppConfig, "api_url"> = {
659
+ root: "http://example.com/deep/nested/path",
660
+ api_prefix: "/api",
661
+ theme: "default",
662
+ version: "1.0.0",
663
+ autoscroll: true
664
+ };
665
+ const result = get_api_url(config);
666
+ expect(result).toBe("http://example.com/deep/nested/path/api");
667
+ });
668
+
669
+ test("root path is '/deep/nested/path/'", () => {
670
+ const config: Omit<AppConfig, "api_url"> = {
671
+ root: "http://example.com/deep/nested/path/",
672
+ api_prefix: "/api",
673
+ theme: "default",
674
+ version: "1.0.0",
675
+ autoscroll: true
676
+ };
677
+ const result = get_api_url(config);
678
+ expect(result).toBe("http://example.com/deep/nested/path/api");
679
+ });
680
+ });
681
+
682
+ describe("different api_prefix formats", () => {
683
+ test("api_prefix with leading slash", () => {
684
+ const config: Omit<AppConfig, "api_url"> = {
685
+ root: "http://example.com/myapp",
686
+ api_prefix: "/api",
687
+ theme: "default",
688
+ version: "1.0.0",
689
+ autoscroll: true
690
+ };
691
+ const result = get_api_url(config);
692
+ expect(result).toBe("http://example.com/myapp/api");
693
+ });
694
+
695
+ test("api_prefix without leading slash", () => {
696
+ const config: Omit<AppConfig, "api_url"> = {
697
+ root: "http://example.com/myapp",
698
+ api_prefix: "api",
699
+ theme: "default",
700
+ version: "1.0.0",
701
+ autoscroll: true
702
+ };
703
+ const result = get_api_url(config);
704
+ expect(result).toBe("http://example.com/myapp/api");
705
+ });
706
+
707
+ test("api_prefix with nested path and leading slash", () => {
708
+ const config: Omit<AppConfig, "api_url"> = {
709
+ root: "http://example.com/myapp",
710
+ api_prefix: "/api/v1",
711
+ theme: "default",
712
+ version: "1.0.0",
713
+ autoscroll: true
714
+ };
715
+ const result = get_api_url(config);
716
+ expect(result).toBe("http://example.com/myapp/api/v1");
717
+ });
718
+
719
+ test("api_prefix with nested path without leading slash", () => {
720
+ const config: Omit<AppConfig, "api_url"> = {
721
+ root: "http://example.com/myapp",
722
+ api_prefix: "api/v1",
723
+ theme: "default",
724
+ version: "1.0.0",
725
+ autoscroll: true
726
+ };
727
+ const result = get_api_url(config);
728
+ expect(result).toBe("http://example.com/myapp/api/v1");
729
+ });
730
+ });
731
+
732
+ describe("edge cases", () => {
733
+ test("root with port number", () => {
734
+ const config: Omit<AppConfig, "api_url"> = {
735
+ root: "http://example.com:8080/myapp",
736
+ api_prefix: "/api",
737
+ theme: "default",
738
+ version: "1.0.0",
739
+ autoscroll: true
740
+ };
741
+ const result = get_api_url(config);
742
+ expect(result).toBe("http://example.com:8080/myapp/api");
743
+ });
744
+
745
+ test("root with HTTPS", () => {
746
+ const config: Omit<AppConfig, "api_url"> = {
747
+ root: "https://example.com/myapp",
748
+ api_prefix: "/api",
749
+ theme: "default",
750
+ version: "1.0.0",
751
+ autoscroll: true
752
+ };
753
+ const result = get_api_url(config);
754
+ expect(result).toBe("https://example.com/myapp/api");
755
+ });
756
+
757
+ test("root with query parameters (should be ignored)", () => {
758
+ const config: Omit<AppConfig, "api_url"> = {
759
+ root: "http://example.com/myapp?param=value",
760
+ api_prefix: "/api",
761
+ theme: "default",
762
+ version: "1.0.0",
763
+ autoscroll: true
764
+ };
765
+ const result = get_api_url(config);
766
+ expect(result).toBe("http://example.com/myapp/api");
767
+ });
768
+
769
+ test("root with hash (should be ignored)", () => {
770
+ const config: Omit<AppConfig, "api_url"> = {
771
+ root: "http://example.com/myapp#section",
772
+ api_prefix: "/api",
773
+ theme: "default",
774
+ version: "1.0.0",
775
+ autoscroll: true
776
+ };
777
+ const result = get_api_url(config);
778
+ expect(result).toBe("http://example.com/myapp/api");
779
+ });
780
+ });
781
+
782
+ describe("consistency checks", () => {
783
+ test("same result regardless of root trailing slash", () => {
784
+ const baseConfig = {
785
+ api_prefix: "/api",
786
+ theme: "default",
787
+ version: "1.0.0",
788
+ autoscroll: true
789
+ };
790
+
791
+ const config1: Omit<AppConfig, "api_url"> = {
792
+ ...baseConfig,
793
+ root: "http://example.com/myapp"
794
+ };
795
+ const config2: Omit<AppConfig, "api_url"> = {
796
+ ...baseConfig,
797
+ root: "http://example.com/myapp/"
798
+ };
799
+
800
+ expect(get_api_url(config1)).toBe(get_api_url(config2));
801
+ });
802
+
803
+ test("same result regardless of api_prefix leading slash", () => {
804
+ const baseConfig = {
805
+ root: "http://example.com/myapp",
806
+ theme: "default",
807
+ version: "1.0.0",
808
+ autoscroll: true
809
+ };
810
+
811
+ const config1: Omit<AppConfig, "api_url"> = {
812
+ ...baseConfig,
813
+ api_prefix: "/api"
814
+ };
815
+ const config2: Omit<AppConfig, "api_url"> = {
816
+ ...baseConfig,
817
+ api_prefix: "api"
818
+ };
819
+
820
+ expect(get_api_url(config1)).toBe(get_api_url(config2));
821
+ });
523
822
  });
524
823
  });