@gradio/core 1.4.0 → 1.4.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @gradio/core
2
2
 
3
+ ## 1.4.1
4
+
5
+ ### Fixes
6
+
7
+ - [#12566](https://github.com/gradio-app/gradio/pull/12566) [`7760161`](https://github.com/gradio-app/gradio/commit/7760161258abe6329b754dd6d2511fc3b61fed95) - Fix custom components in SSR Mode + Custom Component Examples. Thanks @freddyaboulton!
8
+ - [#12803](https://github.com/gradio-app/gradio/pull/12803) [`f4c3a6d`](https://github.com/gradio-app/gradio/commit/f4c3a6dcb45218722d3150baef953c731d3eccf2) - fix: gradio_api path in mount_gradio_app. Thanks @shandowc!
9
+
10
+ ### Dependency updates
11
+
12
+ - @gradio/utils@0.12.1
13
+ - @gradio/statustracker@0.13.0
14
+ - @gradio/gallery@0.17.4
15
+ - @gradio/plot@0.10.6
16
+ - @gradio/textbox@0.13.6
17
+ - @gradio/html@0.12.0
18
+ - @gradio/button@0.6.6
19
+ - @gradio/code@0.17.5
20
+ - @gradio/paramviewer@0.9.6
21
+ - @gradio/checkbox@0.6.5
22
+ - @gradio/image@0.26.0
23
+ - @gradio/video@0.20.5
24
+ - @gradio/file@0.14.5
25
+ - @gradio/audio@0.23.0
26
+ - @gradio/column@0.3.2
27
+ - @gradio/dropdown@0.11.6
28
+
3
29
  ## 1.4.0
4
30
 
5
31
  ### Features
@@ -15,6 +15,7 @@ type Tab = {
15
15
  order?: number;
16
16
  component_id: number;
17
17
  };
18
+ export declare function get_api_url(config: Omit<AppConfig, "api_url">): string;
18
19
  export declare class AppTree {
19
20
  #private;
20
21
  /** Need this to set i18n in re-render */
@@ -8,6 +8,19 @@ const type_map = {
8
8
  walkthrough: "tabs",
9
9
  walkthroughstep: "tabitem"
10
10
  };
11
+ export function get_api_url(config) {
12
+ // Handle api_prefix correctly when app is mounted at a subpath.
13
+ // config.root may not include a trailing slash, so we normalize its pathname
14
+ // before appending api_prefix to ensure correct URL construction.
15
+ const rootUrl = new URL(config.root);
16
+ const rootPath = rootUrl.pathname.endsWith("/")
17
+ ? rootUrl.pathname
18
+ : rootUrl.pathname + "/";
19
+ const apiPrefix = config.api_prefix.startsWith("/")
20
+ ? config.api_prefix
21
+ : "/" + config.api_prefix;
22
+ return new URL(rootPath.slice(0, -1) + apiPrefix, rootUrl.origin).toString();
23
+ }
11
24
  export class AppTree {
12
25
  /** the raw component structure received from the backend */
13
26
  #component_payload;
@@ -45,9 +58,10 @@ export class AppTree {
45
58
  this.ready_resolve = resolve;
46
59
  });
47
60
  this.reactive_formatter = reactive_formatter;
61
+ const api_url = get_api_url(config);
48
62
  this.#config = {
49
63
  ...config,
50
- api_url: new URL(config.api_prefix, config.root).toString()
64
+ api_url
51
65
  };
52
66
  this.#component_payload = components;
53
67
  this.#layout_payload = layout;
@@ -77,9 +91,10 @@ export class AppTree {
77
91
  reload(components, layout, dependencies, config) {
78
92
  this.#layout_payload = layout;
79
93
  this.#component_payload = components;
94
+ const api_url = get_api_url(config);
80
95
  this.#config = {
81
96
  ...config,
82
- api_url: new URL(config.api_prefix, config.root).toString()
97
+ api_url
83
98
  };
84
99
  this.#dependency_payload = dependencies;
85
100
  this.root = this.create_node({ id: layout.id, children: [] }, new Map(), true);
@@ -469,7 +484,7 @@ function gather_props(id, props, dependencies, client, api_url, additional = {})
469
484
  _shared_props.client = client;
470
485
  _shared_props.id = id;
471
486
  _shared_props.interactive = determine_interactivity(id, _shared_props.interactive, _props.value, dependencies);
472
- _shared_props.load_component = (name, variant) => get_component(name, "", api_url, variant).component;
487
+ _shared_props.load_component = (name, variant, component_class_id) => get_component(name, component_class_id || "", api_url, variant);
473
488
  _shared_props.visible =
474
489
  _shared_props.visible === undefined ? true : _shared_props.visible;
475
490
  _shared_props.loading_status = {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,627 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { spy } from "tinyspy";
3
+ import { setupWorker } from "msw/browser";
4
+ import { http, HttpResponse } from "msw";
5
+ import { Dependency, TargetMap } from "./types";
6
+ import { process_frontend_fn, create_target_meta, determine_interactivity, process_server_fn, get_component } from "./_init";
7
+ import { get_api_url } from "./init.svelte";
8
+ import { commands } from "@vitest/browser/context";
9
+ describe("process_frontend_fn", () => {
10
+ test("empty source code returns null", () => {
11
+ const source = "";
12
+ const fn = process_frontend_fn(source, false, 1, 1);
13
+ expect(fn).toBe(null);
14
+ });
15
+ test("falsey source code returns null: false", () => {
16
+ const source = false;
17
+ const fn = process_frontend_fn(source, false, 1, 1);
18
+ expect(fn).toBe(null);
19
+ });
20
+ test("falsey source code returns null: undefined", () => {
21
+ const source = undefined;
22
+ const fn = process_frontend_fn(source, false, 1, 1);
23
+ expect(fn).toBe(null);
24
+ });
25
+ test("falsey source code returns null: null", () => {
26
+ const source = null;
27
+ const fn = process_frontend_fn(source, false, 1, 1);
28
+ expect(fn).toBe(null);
29
+ });
30
+ test("source code returns a function", () => {
31
+ const source = "(arg) => arg";
32
+ const fn = process_frontend_fn(source, false, 1, 1);
33
+ expect(typeof fn).toBe("function");
34
+ });
35
+ test("arrays of values can be passed to the generated function", async () => {
36
+ const source = "(arg) => arg";
37
+ const fn = process_frontend_fn(source, false, 1, 1);
38
+ if (fn) {
39
+ await expect(fn([1])).resolves.toEqual([1]);
40
+ }
41
+ });
42
+ test("arrays of many values can be passed", async () => {
43
+ const source = "(...args) => args";
44
+ const fn = process_frontend_fn(source, false, 1, 1);
45
+ if (fn) {
46
+ await expect(fn([1, 2, 3, 4, 5, 6])).resolves.toEqual([1, 2, 3, 4, 5, 6]);
47
+ }
48
+ });
49
+ test("The generated function returns a promise", () => {
50
+ const source = "(arg) => arg";
51
+ const fn = process_frontend_fn(source, false, 1, 1);
52
+ if (fn) {
53
+ expect(fn([1])).toBeInstanceOf(Promise);
54
+ }
55
+ });
56
+ test("The generated function is callable and returns the expected value", async () => {
57
+ const source = "(arg) => arg";
58
+ const fn = process_frontend_fn(source, false, 1, 1);
59
+ if (fn) {
60
+ await expect(fn([1])).resolves.toEqual([1]);
61
+ }
62
+ });
63
+ test("The return value of the function is wrapped in an array if there is no backend function and the input length is 1", async () => {
64
+ const source = "(arg) => arg";
65
+ const fn = process_frontend_fn(source, false, 1, 1);
66
+ if (fn) {
67
+ await expect(fn([1])).resolves.toEqual([1]);
68
+ }
69
+ });
70
+ test("The return value of the function is not wrapped in an array if there is no backend function and the input length is greater than 1", async () => {
71
+ const source = "(arg) => arg";
72
+ const fn = process_frontend_fn(source, false, 2, 2);
73
+ if (fn) {
74
+ await expect(fn([1])).resolves.toEqual(1);
75
+ }
76
+ });
77
+ test("The return value of the function is wrapped in an array if there is a backend function and the input length is 1", async () => {
78
+ const source = "(arg) => arg";
79
+ const fn = process_frontend_fn(source, true, 1, 1);
80
+ if (fn) {
81
+ await expect(fn([1])).resolves.toEqual([1]);
82
+ }
83
+ });
84
+ test("The return value of the function is not wrapped in an array if there is a backend function and the input length is greater than 1", async () => {
85
+ const source = "(arg) => arg";
86
+ const fn = process_frontend_fn(source, true, 2, 2);
87
+ if (fn) {
88
+ await expect(fn([1])).resolves.toEqual(1);
89
+ }
90
+ });
91
+ });
92
+ describe("create_target_meta", () => {
93
+ test("creates a target map", () => {
94
+ const targets = [
95
+ [1, "change"],
96
+ [2, "input"],
97
+ [3, "load"]
98
+ ];
99
+ const fn_index = 0;
100
+ const target_map = {};
101
+ const result = create_target_meta(targets, fn_index, target_map);
102
+ expect(result).toEqual({
103
+ 1: { change: [0] },
104
+ 2: { input: [0] },
105
+ 3: { load: [0] }
106
+ });
107
+ });
108
+ test("if the target already exists, it adds the new trigger to the list", () => {
109
+ const targets = [
110
+ [1, "change"],
111
+ [1, "input"],
112
+ [1, "load"]
113
+ ];
114
+ const fn_index = 1;
115
+ const target_map = {
116
+ 1: { change: [0] }
117
+ };
118
+ const result = create_target_meta(targets, fn_index, target_map);
119
+ expect(result).toEqual({
120
+ 1: { change: [0, 1], input: [1], load: [1] }
121
+ });
122
+ });
123
+ test("if the trigger already exists, it adds the new function to the list", () => {
124
+ const targets = [
125
+ [1, "change"],
126
+ [2, "change"],
127
+ [3, "change"]
128
+ ];
129
+ const fn_index = 1;
130
+ const target_map = {
131
+ 1: { change: [0] },
132
+ 2: { change: [0] },
133
+ 3: { change: [0] }
134
+ };
135
+ const result = create_target_meta(targets, fn_index, target_map);
136
+ expect(result).toEqual({
137
+ 1: { change: [0, 1] },
138
+ 2: { change: [0, 1] },
139
+ 3: { change: [0, 1] }
140
+ });
141
+ });
142
+ test("if the target and trigger already exist, it adds the new function to the list", () => {
143
+ const targets = [[1, "change"]];
144
+ const fn_index = 1;
145
+ const target_map = {
146
+ 1: { change: [0] }
147
+ };
148
+ const result = create_target_meta(targets, fn_index, target_map);
149
+ expect(result).toEqual({
150
+ 1: { change: [0, 1] }
151
+ });
152
+ });
153
+ test("if the target, trigger and function id already exist, it does not add duplicates", () => {
154
+ const targets = [[1, "change"]];
155
+ const fn_index = 0;
156
+ const target_map = {
157
+ 1: { change: [0] }
158
+ };
159
+ const result = create_target_meta(targets, fn_index, target_map);
160
+ expect(result).toEqual({
161
+ 1: { change: [0] }
162
+ });
163
+ });
164
+ });
165
+ describe("determine_interactivity", () => {
166
+ test("returns true if the prop is interactive = true", () => {
167
+ const result = determine_interactivity(0, true, "hi", new Set([0]), new Set([2]));
168
+ expect(result).toBe(true);
169
+ });
170
+ test("returns false if the prop is interactive = false", () => {
171
+ const result = determine_interactivity(0, false, "hi", new Set([0]), new Set([2]));
172
+ expect(result).toBe(false);
173
+ });
174
+ test("returns true if the component is an input", () => {
175
+ const result = determine_interactivity(0, undefined, "hi", new Set([0]), new Set([2]));
176
+ expect(result).toBe(true);
177
+ });
178
+ test("returns true if the component is not an input or output and the component has no default value: empty string", () => {
179
+ const result = determine_interactivity(2, undefined, "", new Set([0]), new Set([1]));
180
+ expect(result).toBe(true);
181
+ });
182
+ test("returns true if the component is not an input or output and the component has no default value: empty array", () => {
183
+ const result = determine_interactivity(2, undefined, [], new Set([0]), new Set([1]));
184
+ expect(result).toBe(true);
185
+ });
186
+ test("returns true if the component is not an input or output and the component has no default value: boolean", () => {
187
+ const result = determine_interactivity(2, undefined, false, new Set([0]), new Set([1]));
188
+ expect(result).toBe(true);
189
+ });
190
+ test("returns true if the component is not an input or output and the component has no default value: undefined", () => {
191
+ const result = determine_interactivity(2, undefined, undefined, new Set([0]), new Set([1]));
192
+ expect(result).toBe(true);
193
+ });
194
+ test("returns true if the component is not an input or output and the component has no default value: null", () => {
195
+ const result = determine_interactivity(2, undefined, null, new Set([0]), new Set([1]));
196
+ expect(result).toBe(true);
197
+ });
198
+ test("returns true if the component is not an input or output and the component has no default value: 0", () => {
199
+ const result = determine_interactivity(2, undefined, 0, new Set([0]), new Set([1]));
200
+ expect(result).toBe(true);
201
+ });
202
+ test("returns false if the component is not an input or output and the component has a default value", () => {
203
+ const result = determine_interactivity(2, undefined, "hello", new Set([0]), new Set([1]));
204
+ expect(result).toBe(false);
205
+ });
206
+ });
207
+ describe("process_server_fn", () => {
208
+ test("returns an object", () => {
209
+ const result = process_server_fn(1, ["fn1", "fn2"], {});
210
+ expect(result).toBeTypeOf("object");
211
+ });
212
+ test("returns an object with the correct keys", () => {
213
+ const result = process_server_fn(1, ["fn1", "fn2"], {});
214
+ expect(Object.keys(result)).toEqual(["fn1", "fn2"]);
215
+ });
216
+ test("returns an object with the correct keys and values", () => {
217
+ const app = {
218
+ component_server: async (id, fn, args) => {
219
+ return args;
220
+ }
221
+ };
222
+ const result = process_server_fn(1, ["fn1", "fn2"], app);
223
+ expect(Object.keys(result)).toEqual(["fn1", "fn2"]);
224
+ expect(result.fn1).toBeInstanceOf(Function);
225
+ expect(result.fn2).toBeInstanceOf(Function);
226
+ });
227
+ test("returned server functions should resolve to a promise", async () => {
228
+ const app = {
229
+ component_server: async (id, fn, args) => {
230
+ return args;
231
+ }
232
+ };
233
+ const result = process_server_fn(1, ["fn1", "fn2"], app);
234
+ const response = result.fn1("hello");
235
+ expect(response).toBeInstanceOf(Promise);
236
+ });
237
+ test("the functions call the clients component_server function with the correct arguments ", async () => {
238
+ const mock = spy(async (id, fn, args) => {
239
+ return args;
240
+ });
241
+ const app = {
242
+ component_server: mock
243
+ };
244
+ const result = process_server_fn(1, ["fn1", "fn2"], app);
245
+ const response = await result.fn1("hello");
246
+ expect(response).toBe("hello");
247
+ expect(mock.calls).toEqual([[1, "fn1", "hello"]]);
248
+ });
249
+ test("if there are no server functions, it returns an empty object", () => {
250
+ const result = process_server_fn(1, undefined, {});
251
+ expect(result).toEqual({});
252
+ });
253
+ });
254
+ describe("get_component", () => {
255
+ test("returns an object", () => {
256
+ const result = get_component("test-component-one", "class_id", "root", []);
257
+ expect(result.component).toBeTypeOf("object");
258
+ });
259
+ test("returns an object with the correct keys", () => {
260
+ const result = get_component("test-component-one", "class_id", "root", []);
261
+ expect(Object.keys(result)).toEqual([
262
+ "component",
263
+ "name",
264
+ "example_components"
265
+ ]);
266
+ });
267
+ test("the component key is a promise", () => {
268
+ const result = get_component("test-component-one", "class_id", "root", []);
269
+ expect(result.component).toBeInstanceOf(Promise);
270
+ });
271
+ test("the resolved component key is an object", async () => {
272
+ const result = get_component("test-component-one", "class_id", "root", []);
273
+ const o = await result.component;
274
+ expect(o).toBeTypeOf("object");
275
+ });
276
+ test("getting the same component twice should return the same promise", () => {
277
+ const result = get_component("test-component-one", "class_id", "root", []);
278
+ const result_two = get_component("test-component-one", "class_id", "root", []);
279
+ expect(result.component).toBe(result_two.component);
280
+ });
281
+ test("if example components are not provided, the example_components key is undefined", async () => {
282
+ const result = get_component("dataset", "class_id", "root", []);
283
+ expect(result.example_components).toBe(undefined);
284
+ });
285
+ test("if the type is not a dataset, the example_components key is undefined", async () => {
286
+ const result = get_component("test-component-one", "class_id", "root", []);
287
+ expect(result.example_components).toBe(undefined);
288
+ });
289
+ test("when the type is a dataset, returns an object with the correct keys and values and example components", () => {
290
+ const result = get_component("dataset", "class_id", "root", [
291
+ {
292
+ type: "test-component-one",
293
+ component_class_id: "example_class_id",
294
+ id: 1,
295
+ props: {
296
+ value: "hi",
297
+ interactive: false
298
+ },
299
+ has_modes: false,
300
+ instance: {},
301
+ component: {}
302
+ }
303
+ ], ["test-component-one"]);
304
+ expect(result.component).toBeTypeOf("object");
305
+ expect(result.example_components).toBeInstanceOf(Map);
306
+ });
307
+ test("when example components are returned, returns an object with the correct keys and values and example components", () => {
308
+ const result = get_component("dataset", "class_id", "root", [
309
+ {
310
+ type: "test-component-one",
311
+ component_class_id: "example_class_id",
312
+ id: 1,
313
+ props: {
314
+ value: "hi",
315
+ interactive: false
316
+ },
317
+ key: "test-component-one",
318
+ has_modes: false,
319
+ instance: {},
320
+ component: {}
321
+ }
322
+ ], ["test-component-one"]);
323
+ expect(result.example_components?.get("test-component-one")).toBeTypeOf("object");
324
+ expect(result.example_components?.get("test-component-one")).toBeInstanceOf(Promise);
325
+ });
326
+ test.skip("if the component is not found then it should request the component from the server", async () => {
327
+ const api_url = "example.com";
328
+ const id = "test-random";
329
+ const variant = "component";
330
+ const handlers = [
331
+ http.get(`${api_url}/custom_component/${id}/client/${variant}/style.css`, () => {
332
+ return new HttpResponse('console.log("boo")', {
333
+ status: 200,
334
+ headers: {
335
+ "Content-Type": "text/css"
336
+ }
337
+ });
338
+ })
339
+ ];
340
+ // vi.mock calls are always hoisted out of the test function to the top of the file
341
+ // so we need to use vi.hoisted to hoist the mock function above the vi.mock call
342
+ const { mock } = vi.hoisted(() => {
343
+ return { mock: vi.fn() };
344
+ });
345
+ vi.mock(`example.com/custom_component/test-random/client/component/index.js`, async () => {
346
+ mock();
347
+ return {
348
+ default: {
349
+ default: "HELLO"
350
+ }
351
+ };
352
+ });
353
+ const worker = setupWorker(...handlers);
354
+ worker.start();
355
+ await get_component("test-random", id, api_url, []).component;
356
+ expect(mock).toHaveBeenCalled();
357
+ worker.stop();
358
+ });
359
+ });
360
+ describe("get_api_url", () => {
361
+ describe("root URL with trailing slash", () => {
362
+ test("root with trailing slash, api_prefix with leading slash", () => {
363
+ const config = {
364
+ root: "http://example.com/myapp/",
365
+ api_prefix: "/api",
366
+ theme: "default",
367
+ version: "1.0.0",
368
+ autoscroll: true
369
+ };
370
+ const result = get_api_url(config);
371
+ expect(result).toBe("http://example.com/myapp/api");
372
+ });
373
+ test("root with trailing slash, api_prefix without leading slash", () => {
374
+ const config = {
375
+ root: "http://example.com/myapp/",
376
+ api_prefix: "api",
377
+ theme: "default",
378
+ version: "1.0.0",
379
+ autoscroll: true
380
+ };
381
+ const result = get_api_url(config);
382
+ expect(result).toBe("http://example.com/myapp/api");
383
+ });
384
+ test("root at domain root with trailing slash", () => {
385
+ const config = {
386
+ root: "http://example.com/",
387
+ api_prefix: "/api",
388
+ theme: "default",
389
+ version: "1.0.0",
390
+ autoscroll: true
391
+ };
392
+ const result = get_api_url(config);
393
+ expect(result).toBe("http://example.com/api");
394
+ });
395
+ });
396
+ describe("root URL without trailing slash", () => {
397
+ test("root without trailing slash, api_prefix with leading slash", () => {
398
+ const config = {
399
+ root: "http://example.com/myapp",
400
+ api_prefix: "/api",
401
+ theme: "default",
402
+ version: "1.0.0",
403
+ autoscroll: true
404
+ };
405
+ const result = get_api_url(config);
406
+ expect(result).toBe("http://example.com/myapp/api");
407
+ });
408
+ test("root without trailing slash, api_prefix without leading slash", () => {
409
+ const config = {
410
+ root: "http://example.com/myapp",
411
+ api_prefix: "api",
412
+ theme: "default",
413
+ version: "1.0.0",
414
+ autoscroll: true
415
+ };
416
+ const result = get_api_url(config);
417
+ expect(result).toBe("http://example.com/myapp/api");
418
+ });
419
+ test("root at domain root without trailing slash", () => {
420
+ const config = {
421
+ root: "http://example.com",
422
+ api_prefix: "/api",
423
+ theme: "default",
424
+ version: "1.0.0",
425
+ autoscroll: true
426
+ };
427
+ const result = get_api_url(config);
428
+ expect(result).toBe("http://example.com/api");
429
+ });
430
+ });
431
+ describe("different root path combinations", () => {
432
+ test("root path is just '/'", () => {
433
+ const config = {
434
+ root: "http://example.com/",
435
+ api_prefix: "/api",
436
+ theme: "default",
437
+ version: "1.0.0",
438
+ autoscroll: true
439
+ };
440
+ const result = get_api_url(config);
441
+ expect(result).toBe("http://example.com/api");
442
+ });
443
+ test("root path is '/' without trailing slash", () => {
444
+ const config = {
445
+ root: "http://example.com",
446
+ api_prefix: "/api",
447
+ theme: "default",
448
+ version: "1.0.0",
449
+ autoscroll: true
450
+ };
451
+ const result = get_api_url(config);
452
+ expect(result).toBe("http://example.com/api");
453
+ });
454
+ test("root path is '/myapp'", () => {
455
+ const config = {
456
+ root: "http://example.com/myapp",
457
+ api_prefix: "/api",
458
+ theme: "default",
459
+ version: "1.0.0",
460
+ autoscroll: true
461
+ };
462
+ const result = get_api_url(config);
463
+ expect(result).toBe("http://example.com/myapp/api");
464
+ });
465
+ test("root path is '/myapp/'", () => {
466
+ const config = {
467
+ root: "http://example.com/myapp/",
468
+ api_prefix: "/api",
469
+ theme: "default",
470
+ version: "1.0.0",
471
+ autoscroll: true
472
+ };
473
+ const result = get_api_url(config);
474
+ expect(result).toBe("http://example.com/myapp/api");
475
+ });
476
+ test("root path is '/deep/nested/path'", () => {
477
+ const config = {
478
+ root: "http://example.com/deep/nested/path",
479
+ api_prefix: "/api",
480
+ theme: "default",
481
+ version: "1.0.0",
482
+ autoscroll: true
483
+ };
484
+ const result = get_api_url(config);
485
+ expect(result).toBe("http://example.com/deep/nested/path/api");
486
+ });
487
+ test("root path is '/deep/nested/path/'", () => {
488
+ const config = {
489
+ root: "http://example.com/deep/nested/path/",
490
+ api_prefix: "/api",
491
+ theme: "default",
492
+ version: "1.0.0",
493
+ autoscroll: true
494
+ };
495
+ const result = get_api_url(config);
496
+ expect(result).toBe("http://example.com/deep/nested/path/api");
497
+ });
498
+ });
499
+ describe("different api_prefix formats", () => {
500
+ test("api_prefix with leading slash", () => {
501
+ const config = {
502
+ root: "http://example.com/myapp",
503
+ api_prefix: "/api",
504
+ theme: "default",
505
+ version: "1.0.0",
506
+ autoscroll: true
507
+ };
508
+ const result = get_api_url(config);
509
+ expect(result).toBe("http://example.com/myapp/api");
510
+ });
511
+ test("api_prefix without leading slash", () => {
512
+ const config = {
513
+ root: "http://example.com/myapp",
514
+ api_prefix: "api",
515
+ theme: "default",
516
+ version: "1.0.0",
517
+ autoscroll: true
518
+ };
519
+ const result = get_api_url(config);
520
+ expect(result).toBe("http://example.com/myapp/api");
521
+ });
522
+ test("api_prefix with nested path and leading slash", () => {
523
+ const config = {
524
+ root: "http://example.com/myapp",
525
+ api_prefix: "/api/v1",
526
+ theme: "default",
527
+ version: "1.0.0",
528
+ autoscroll: true
529
+ };
530
+ const result = get_api_url(config);
531
+ expect(result).toBe("http://example.com/myapp/api/v1");
532
+ });
533
+ test("api_prefix with nested path without leading slash", () => {
534
+ const config = {
535
+ root: "http://example.com/myapp",
536
+ api_prefix: "api/v1",
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/v1");
543
+ });
544
+ });
545
+ describe("edge cases", () => {
546
+ test("root with port number", () => {
547
+ const config = {
548
+ root: "http://example.com:8080/myapp",
549
+ api_prefix: "/api",
550
+ theme: "default",
551
+ version: "1.0.0",
552
+ autoscroll: true
553
+ };
554
+ const result = get_api_url(config);
555
+ expect(result).toBe("http://example.com:8080/myapp/api");
556
+ });
557
+ test("root with HTTPS", () => {
558
+ const config = {
559
+ root: "https://example.com/myapp",
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("https://example.com/myapp/api");
567
+ });
568
+ test("root with query parameters (should be ignored)", () => {
569
+ const config = {
570
+ root: "http://example.com/myapp?param=value",
571
+ api_prefix: "/api",
572
+ theme: "default",
573
+ version: "1.0.0",
574
+ autoscroll: true
575
+ };
576
+ const result = get_api_url(config);
577
+ expect(result).toBe("http://example.com/myapp/api");
578
+ });
579
+ test("root with hash (should be ignored)", () => {
580
+ const config = {
581
+ root: "http://example.com/myapp#section",
582
+ api_prefix: "/api",
583
+ theme: "default",
584
+ version: "1.0.0",
585
+ autoscroll: true
586
+ };
587
+ const result = get_api_url(config);
588
+ expect(result).toBe("http://example.com/myapp/api");
589
+ });
590
+ });
591
+ describe("consistency checks", () => {
592
+ test("same result regardless of root trailing slash", () => {
593
+ const baseConfig = {
594
+ api_prefix: "/api",
595
+ theme: "default",
596
+ version: "1.0.0",
597
+ autoscroll: true
598
+ };
599
+ const config1 = {
600
+ ...baseConfig,
601
+ root: "http://example.com/myapp"
602
+ };
603
+ const config2 = {
604
+ ...baseConfig,
605
+ root: "http://example.com/myapp/"
606
+ };
607
+ expect(get_api_url(config1)).toBe(get_api_url(config2));
608
+ });
609
+ test("same result regardless of api_prefix leading slash", () => {
610
+ const baseConfig = {
611
+ root: "http://example.com/myapp",
612
+ theme: "default",
613
+ version: "1.0.0",
614
+ autoscroll: true
615
+ };
616
+ const config1 = {
617
+ ...baseConfig,
618
+ api_prefix: "/api"
619
+ };
620
+ const config2 = {
621
+ ...baseConfig,
622
+ api_prefix: "api"
623
+ };
624
+ expect(get_api_url(config1)).toBe(get_api_url(config2));
625
+ });
626
+ });
627
+ });
package/package.json CHANGED
@@ -1,67 +1,67 @@
1
1
  {
2
2
  "name": "@gradio/core",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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",
6
+ "@gradio/accordion": "^0.5.33",
9
7
  "@gradio/atoms": "^0.22.2",
8
+ "@gradio/annotatedimage": "^0.11.5",
9
+ "@gradio/audio": "^0.23.0",
10
10
  "@gradio/box": "^0.2.30",
11
- "@gradio/button": "^0.6.5",
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/chatbot": "^0.29.6",
14
+ "@gradio/checkbox": "^0.6.5",
15
+ "@gradio/checkboxgroup": "^0.10.0",
16
+ "@gradio/code": "^0.17.5",
16
17
  "@gradio/client": "^2.1.0",
17
- "@gradio/code": "^0.17.4",
18
- "@gradio/colorpicker": "^0.5.7",
19
18
  "@gradio/column": "^0.3.2",
20
- "@gradio/datetime": "^0.4.4",
21
- "@gradio/dataframe": "^0.21.7",
19
+ "@gradio/dataframe": "^0.22.0",
20
+ "@gradio/dataset": "^0.5.6",
21
+ "@gradio/colorpicker": "^0.5.8",
22
+ "@gradio/datetime": "^0.4.5",
22
23
  "@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",
24
+ "@gradio/dropdown": "^0.11.6",
25
+ "@gradio/fallback": "^0.4.36",
26
+ "@gradio/file": "^0.14.5",
27
+ "@gradio/fileexplorer": "^0.6.5",
29
28
  "@gradio/form": "^0.3.1",
29
+ "@gradio/gallery": "^0.17.4",
30
30
  "@gradio/group": "^0.3.3",
31
- "@gradio/html": "^0.11.1",
32
- "@gradio/highlightedtext": "^0.11.3",
31
+ "@gradio/highlightedtext": "^0.11.4",
32
+ "@gradio/html": "^0.12.0",
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/imageeditor": "^0.18.8",
35
+ "@gradio/image": "^0.26.0",
36
+ "@gradio/imageslider": "^0.4.5",
37
+ "@gradio/json": "^0.7.4",
38
+ "@gradio/model3d": "^0.16.6",
39
+ "@gradio/markdown": "^0.13.30",
40
+ "@gradio/multimodaltextbox": "^0.11.8",
41
+ "@gradio/label": "^0.6.5",
42
+ "@gradio/nativeplot": "^0.10.4",
43
+ "@gradio/number": "^0.8.5",
44
+ "@gradio/radio": "^0.10.0",
45
+ "@gradio/plot": "^0.10.6",
46
+ "@gradio/paramviewer": "^0.9.6",
51
47
  "@gradio/row": "^0.3.1",
48
+ "@gradio/simpleimage": "^0.9.7",
49
+ "@gradio/simpledropdown": "^0.3.36",
50
+ "@gradio/simpletextbox": "^0.3.38",
51
+ "@gradio/sidebar": "^0.2.5",
52
52
  "@gradio/state": "^0.2.3",
53
- "@gradio/slider": "^0.7.7",
54
- "@gradio/tabitem": "^0.6.6",
55
- "@gradio/statustracker": "^0.12.5",
56
- "@gradio/textbox": "^0.13.5",
53
+ "@gradio/slider": "^0.7.8",
54
+ "@gradio/statustracker": "^0.13.0",
57
55
  "@gradio/tabs": "^0.5.8",
56
+ "@gradio/tabitem": "^0.6.6",
57
+ "@gradio/textbox": "^0.13.6",
58
58
  "@gradio/theme": "^0.6.1",
59
59
  "@gradio/timer": "^0.4.9",
60
60
  "@gradio/upload": "^0.17.7",
61
- "@gradio/utils": "^0.12.0",
61
+ "@gradio/utils": "^0.12.1",
62
62
  "@gradio/uploadbutton": "^0.9.18",
63
- "@gradio/video": "^0.20.4",
64
- "@gradio/vibeeditor": "^0.3.6"
63
+ "@gradio/vibeeditor": "^0.3.7",
64
+ "@gradio/video": "^0.20.5"
65
65
  },
66
66
  "msw": {
67
67
  "workerDirectory": "public"
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
 
@@ -671,8 +687,9 @@ function gather_props(
671
687
 
672
688
  _shared_props.load_component = (
673
689
  name: string,
674
- variant: "base" | "component" | "example"
675
- ) => get_component(name, "", api_url, variant).component as LoadingComponent;
690
+ variant: "base" | "component" | "example",
691
+ component_class_id?: string
692
+ ) => get_component(name, component_class_id || "", api_url, variant);
676
693
 
677
694
  _shared_props.visible =
678
695
  _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
  });