@gradio/code 0.17.6 → 0.17.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.
- package/CHANGELOG.md +11 -0
- package/Code.test.ts +440 -0
- package/Download.test.ts +121 -0
- package/Index.svelte +1 -1
- package/dist/Index.svelte +1 -1
- package/dist/shared/Copy.svelte +1 -1
- package/dist/shared/Download.svelte +1 -1
- package/package.json +4 -4
- package/shared/Copy.svelte +1 -1
- package/shared/Download.svelte +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @gradio/code
|
|
2
2
|
|
|
3
|
+
## 0.17.7
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- [#13222](https://github.com/gradio-app/gradio/pull/13222) [`10b22a0`](https://github.com/gradio-app/gradio/commit/10b22a02b7cccccbd2cfee624040b3092a4347f0) - code tests. Thanks @pngwn!
|
|
8
|
+
|
|
9
|
+
### Dependency updates
|
|
10
|
+
|
|
11
|
+
- @gradio/atoms@0.23.1
|
|
12
|
+
- @gradio/statustracker@0.14.0
|
|
13
|
+
|
|
3
14
|
## 0.17.6
|
|
4
15
|
|
|
5
16
|
### Dependency updates
|
package/Code.test.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { test, describe, afterEach, expect, vi } from "vitest";
|
|
2
|
+
import { cleanup, render, fireEvent, waitFor } from "@self/tootils/render";
|
|
3
|
+
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
|
|
4
|
+
import event from "@testing-library/user-event";
|
|
5
|
+
|
|
6
|
+
import Code from "./Index.svelte";
|
|
7
|
+
|
|
8
|
+
const default_props = {
|
|
9
|
+
value: "print('hello')",
|
|
10
|
+
language: "python",
|
|
11
|
+
lines: 5,
|
|
12
|
+
interactive: true,
|
|
13
|
+
show_label: true,
|
|
14
|
+
label: "Code"
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Code uses BlockLabel (not BlockTitle/block-info) and conditionally renders it
|
|
18
|
+
// via {#if show_label}, so the shared show_label:false test (which expects the
|
|
19
|
+
// label to remain in the DOM with sr-only) does not apply. Custom label tests
|
|
20
|
+
// are written below in the "Props: show_label" describe block.
|
|
21
|
+
run_shared_prop_tests({
|
|
22
|
+
component: Code,
|
|
23
|
+
name: "Code",
|
|
24
|
+
base_props: {
|
|
25
|
+
value: "print('hello')",
|
|
26
|
+
language: "python",
|
|
27
|
+
lines: 5
|
|
28
|
+
},
|
|
29
|
+
has_label: false
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("Props: show_label", () => {
|
|
33
|
+
afterEach(() => cleanup());
|
|
34
|
+
|
|
35
|
+
test("show_label=true renders the label text", async () => {
|
|
36
|
+
const { getByText } = await render(Code, {
|
|
37
|
+
...default_props,
|
|
38
|
+
label: "My Code Editor",
|
|
39
|
+
show_label: true
|
|
40
|
+
});
|
|
41
|
+
expect(getByText("My Code Editor")).toBeVisible();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("show_label=false hides the label from view", async () => {
|
|
45
|
+
const { queryByText } = await render(Code, {
|
|
46
|
+
...default_props,
|
|
47
|
+
label: "My Code Editor",
|
|
48
|
+
show_label: false
|
|
49
|
+
});
|
|
50
|
+
// Code uses {#if show_label} so the BlockLabel is not rendered at all.
|
|
51
|
+
expect(queryByText("My Code Editor")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("label text is derived from the label prop", async () => {
|
|
55
|
+
const { getByText } = await render(Code, {
|
|
56
|
+
...default_props,
|
|
57
|
+
label: "Custom Label Text",
|
|
58
|
+
show_label: true
|
|
59
|
+
});
|
|
60
|
+
expect(getByText("Custom Label Text")).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("Code", () => {
|
|
65
|
+
afterEach(() => cleanup());
|
|
66
|
+
|
|
67
|
+
test("renders the code editor when a value is provided", async () => {
|
|
68
|
+
const { getByLabelText } = await render(Code, default_props);
|
|
69
|
+
expect(getByLabelText("Code input container")).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("renders the editor when interactive with no value", async () => {
|
|
73
|
+
const { getByLabelText } = await render(Code, {
|
|
74
|
+
...default_props,
|
|
75
|
+
value: ""
|
|
76
|
+
});
|
|
77
|
+
expect(getByLabelText("Code input container")).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("renders empty state (no editor) when value is empty and not interactive", async () => {
|
|
81
|
+
const { queryByLabelText } = await render(Code, {
|
|
82
|
+
...default_props,
|
|
83
|
+
value: "",
|
|
84
|
+
interactive: false
|
|
85
|
+
});
|
|
86
|
+
expect(queryByLabelText("Code input container")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("renders the editor (not empty state) when value is present and not interactive", async () => {
|
|
90
|
+
const { getByLabelText } = await render(Code, {
|
|
91
|
+
...default_props,
|
|
92
|
+
interactive: false
|
|
93
|
+
});
|
|
94
|
+
expect(getByLabelText("Code input container")).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("Props: interactive", () => {
|
|
99
|
+
afterEach(() => cleanup());
|
|
100
|
+
|
|
101
|
+
test("interactive=true makes the editor contenteditable", async () => {
|
|
102
|
+
const { getByLabelText } = await render(Code, {
|
|
103
|
+
...default_props,
|
|
104
|
+
interactive: true
|
|
105
|
+
});
|
|
106
|
+
const editor = getByLabelText("Code input container");
|
|
107
|
+
expect(editor).toHaveAttribute("contenteditable", "true");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("interactive=false makes the editor read-only", async () => {
|
|
111
|
+
const { getByLabelText } = await render(Code, {
|
|
112
|
+
...default_props,
|
|
113
|
+
interactive: false
|
|
114
|
+
});
|
|
115
|
+
const editor = getByLabelText("Code input container");
|
|
116
|
+
expect(editor).toHaveAttribute("contenteditable", "false");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("Props: buttons", () => {
|
|
121
|
+
afterEach(() => cleanup());
|
|
122
|
+
|
|
123
|
+
test("default (null) shows both copy and download buttons", async () => {
|
|
124
|
+
const { getByLabelText, getByRole } = await render(Code, {
|
|
125
|
+
...default_props,
|
|
126
|
+
buttons: null
|
|
127
|
+
});
|
|
128
|
+
expect(getByLabelText("Copy")).toBeTruthy();
|
|
129
|
+
expect(getByLabelText("Download")).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("buttons=['copy', 'download'] shows both buttons", async () => {
|
|
133
|
+
const { getByLabelText } = await render(Code, {
|
|
134
|
+
...default_props,
|
|
135
|
+
buttons: ["copy", "download"]
|
|
136
|
+
});
|
|
137
|
+
expect(getByLabelText("Copy")).toBeTruthy();
|
|
138
|
+
expect(getByLabelText("Download")).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("buttons=['copy'] shows only the copy button", async () => {
|
|
142
|
+
const { getByLabelText, queryByLabelText } = await render(Code, {
|
|
143
|
+
...default_props,
|
|
144
|
+
buttons: ["copy"]
|
|
145
|
+
});
|
|
146
|
+
expect(getByLabelText("Copy")).toBeTruthy();
|
|
147
|
+
expect(queryByLabelText("Download")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("buttons=['download'] shows only the download button", async () => {
|
|
151
|
+
const { getByLabelText, queryByLabelText } = await render(Code, {
|
|
152
|
+
...default_props,
|
|
153
|
+
buttons: ["download"]
|
|
154
|
+
});
|
|
155
|
+
expect(getByLabelText("Download")).toBeTruthy();
|
|
156
|
+
expect(queryByLabelText("Copy")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("buttons=[] shows no copy or download buttons", async () => {
|
|
160
|
+
const { queryByLabelText } = await render(Code, {
|
|
161
|
+
...default_props,
|
|
162
|
+
buttons: []
|
|
163
|
+
});
|
|
164
|
+
expect(queryByLabelText("Copy")).toBeNull();
|
|
165
|
+
expect(queryByLabelText("Download")).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("custom button is rendered in the toolbar", async () => {
|
|
169
|
+
const { getByRole } = await render(Code, {
|
|
170
|
+
...default_props,
|
|
171
|
+
buttons: [{ id: 42, value: "Run", icon: null }]
|
|
172
|
+
});
|
|
173
|
+
expect(getByRole("button", { name: "Run" })).toBeTruthy();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("clicking a custom button fires custom_button_click with the button id", async () => {
|
|
177
|
+
const { listen, getByRole } = await render(Code, {
|
|
178
|
+
...default_props,
|
|
179
|
+
buttons: [{ id: 99, value: "Execute", icon: null }]
|
|
180
|
+
});
|
|
181
|
+
const custom = listen("custom_button_click");
|
|
182
|
+
await fireEvent.click(getByRole("button", { name: "Execute" }));
|
|
183
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(custom).toHaveBeenCalledWith({ id: 99 });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("Props: language", () => {
|
|
189
|
+
afterEach(() => cleanup());
|
|
190
|
+
|
|
191
|
+
const languages = [
|
|
192
|
+
"python",
|
|
193
|
+
"javascript",
|
|
194
|
+
"typescript",
|
|
195
|
+
"json",
|
|
196
|
+
"html",
|
|
197
|
+
"css",
|
|
198
|
+
"markdown",
|
|
199
|
+
"shell",
|
|
200
|
+
"sql",
|
|
201
|
+
"yaml",
|
|
202
|
+
"dockerfile",
|
|
203
|
+
"r",
|
|
204
|
+
"c",
|
|
205
|
+
"cpp"
|
|
206
|
+
] as const;
|
|
207
|
+
|
|
208
|
+
for (const lang of languages) {
|
|
209
|
+
test(`language='${lang}' renders without error`, async () => {
|
|
210
|
+
const { getByLabelText } = await render(Code, {
|
|
211
|
+
...default_props,
|
|
212
|
+
language: lang
|
|
213
|
+
});
|
|
214
|
+
expect(getByLabelText("Code input container")).toBeTruthy();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test("language=null renders without error", async () => {
|
|
219
|
+
const { getByLabelText } = await render(Code, {
|
|
220
|
+
...default_props,
|
|
221
|
+
language: null
|
|
222
|
+
});
|
|
223
|
+
expect(getByLabelText("Code input container")).toBeTruthy();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test.todo(
|
|
228
|
+
"VISUAL: lines=10 sets the minimum visible height of the editor to 10 line-heights — needs Playwright screenshot comparison"
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
test.todo(
|
|
232
|
+
"VISUAL: max_lines=3 caps the editor height at 3 line-heights and enables scrolling for longer content — needs Playwright screenshot comparison"
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
test.todo(
|
|
236
|
+
"VISUAL: show_line_numbers=true renders the line number gutter — needs Playwright screenshot comparison"
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
test.todo(
|
|
240
|
+
"VISUAL: show_line_numbers=false hides the line number gutter — needs Playwright screenshot comparison"
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
test.todo(
|
|
244
|
+
"VISUAL: wrap_lines=true wraps long lines within the container width instead of scrolling — needs Playwright screenshot comparison"
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
test.todo(
|
|
248
|
+
"VISUAL: wrap_lines=false lets long lines overflow horizontally — needs Playwright screenshot comparison"
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
test.todo(
|
|
252
|
+
"VISUAL: autocomplete=true shows an autocompletion dropdown when typing in supported languages — needs Playwright interaction test"
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
describe("Events", () => {
|
|
256
|
+
afterEach(() => cleanup());
|
|
257
|
+
|
|
258
|
+
test("change: not fired on initial mount", async () => {
|
|
259
|
+
const { listen } = await render(Code, default_props);
|
|
260
|
+
// retrospective: true replays any events buffered during render
|
|
261
|
+
const change = listen("change", { retrospective: true });
|
|
262
|
+
expect(change).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("change: fired when value is updated via set_data", async () => {
|
|
266
|
+
const { listen, set_data } = await render(Code, default_props);
|
|
267
|
+
const change = listen("change");
|
|
268
|
+
await set_data({ value: "print('world')" });
|
|
269
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("change: not fired when the same value is set twice", async () => {
|
|
273
|
+
const { listen, set_data } = await render(Code, default_props);
|
|
274
|
+
const change = listen("change");
|
|
275
|
+
await set_data({ value: "x = 1" });
|
|
276
|
+
await set_data({ value: "x = 1" });
|
|
277
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("input: fired when the user types in the editor", async () => {
|
|
281
|
+
const { listen, getByLabelText } = await render(Code, default_props);
|
|
282
|
+
const input = listen("input");
|
|
283
|
+
const editor = getByLabelText("Code input container");
|
|
284
|
+
editor.focus();
|
|
285
|
+
await event.keyboard("a");
|
|
286
|
+
await waitFor(() => expect(input).toHaveBeenCalledTimes(1));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("focus: fired when the editor gains focus", async () => {
|
|
290
|
+
const { listen, getByLabelText } = await render(Code, default_props);
|
|
291
|
+
const focus = listen("focus");
|
|
292
|
+
const editor = getByLabelText("Code input container");
|
|
293
|
+
editor.focus();
|
|
294
|
+
expect(focus).toHaveBeenCalledTimes(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("blur: fired when the editor loses focus", async () => {
|
|
298
|
+
const { listen, getByLabelText } = await render(Code, default_props);
|
|
299
|
+
const blur = listen("blur");
|
|
300
|
+
const editor = getByLabelText("Code input container");
|
|
301
|
+
editor.focus();
|
|
302
|
+
editor.blur();
|
|
303
|
+
expect(blur).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("custom_button_click: carries the button id in the payload", async () => {
|
|
307
|
+
const { listen, getByRole } = await render(Code, {
|
|
308
|
+
...default_props,
|
|
309
|
+
buttons: [{ id: 7, value: "Go", icon: null }]
|
|
310
|
+
});
|
|
311
|
+
const custom = listen("custom_button_click");
|
|
312
|
+
await fireEvent.click(getByRole("button", { name: "Go" }));
|
|
313
|
+
expect(custom).toHaveBeenCalledWith({ id: 7 });
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("get_data / set_data", () => {
|
|
318
|
+
afterEach(() => cleanup());
|
|
319
|
+
|
|
320
|
+
test("get_data returns the initial value", async () => {
|
|
321
|
+
const { get_data } = await render(Code, default_props);
|
|
322
|
+
const data = await get_data();
|
|
323
|
+
expect(data.value).toBe("print('hello')");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("set_data updates the value returned by get_data", async () => {
|
|
327
|
+
const { get_data, set_data } = await render(Code, default_props);
|
|
328
|
+
await set_data({ value: "x = 42" });
|
|
329
|
+
const data = await get_data();
|
|
330
|
+
expect(data.value).toBe("x = 42");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("set_data / get_data round-trip", async () => {
|
|
334
|
+
const { get_data, set_data } = await render(Code, default_props);
|
|
335
|
+
const new_value = "def foo():\n return 1";
|
|
336
|
+
await set_data({ value: new_value });
|
|
337
|
+
const data = await get_data();
|
|
338
|
+
expect(data.value).toBe(new_value);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("user typing is reflected in get_data", async () => {
|
|
342
|
+
const { get_data, getByLabelText } = await render(Code, {
|
|
343
|
+
...default_props,
|
|
344
|
+
value: ""
|
|
345
|
+
});
|
|
346
|
+
const editor = getByLabelText("Code input container");
|
|
347
|
+
editor.focus();
|
|
348
|
+
await event.keyboard("hi");
|
|
349
|
+
await waitFor(async () => {
|
|
350
|
+
const data = await get_data();
|
|
351
|
+
expect(data.value).toContain("hi");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("Copy button", () => {
|
|
357
|
+
afterEach(() => cleanup());
|
|
358
|
+
|
|
359
|
+
test("clicking the copy button writes the code value to the clipboard", async () => {
|
|
360
|
+
// navigator.clipboard is unavailable in insecure (non-HTTPS) test origins
|
|
361
|
+
// so we mock writeText to prevent the silent no-op in Copy.svelte's `if
|
|
362
|
+
// ("clipboard" in navigator)` guard. This mock makes clipboard available.
|
|
363
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
364
|
+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
365
|
+
writable: true,
|
|
366
|
+
configurable: true
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const { getByLabelText } = await render(Code, {
|
|
370
|
+
...default_props,
|
|
371
|
+
value: "my code"
|
|
372
|
+
});
|
|
373
|
+
await fireEvent.click(getByLabelText("Copy"));
|
|
374
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("my code");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test.todo(
|
|
378
|
+
"VISUAL: copy button icon changes from Copy to Check for ~2 seconds after clicking — needs Playwright screenshot comparison"
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("Download button", () => {
|
|
383
|
+
afterEach(() => cleanup());
|
|
384
|
+
|
|
385
|
+
test("download link has filename extension matching the language", async () => {
|
|
386
|
+
const { getByRole } = await render(Code, {
|
|
387
|
+
...default_props,
|
|
388
|
+
language: "python",
|
|
389
|
+
value: "print('hello')"
|
|
390
|
+
});
|
|
391
|
+
// The Download button is inside an <a download="file.py"> anchor
|
|
392
|
+
const link = getByRole("link");
|
|
393
|
+
expect(link).toHaveAttribute("download", "file.py");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("download link href is a blob URL", async () => {
|
|
397
|
+
const { getByRole } = await render(Code, {
|
|
398
|
+
...default_props,
|
|
399
|
+
value: "some code"
|
|
400
|
+
});
|
|
401
|
+
const link = getByRole("link");
|
|
402
|
+
expect(link.getAttribute("href")).toMatch(/^blob:/);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test.todo(
|
|
406
|
+
"VISUAL: download button icon changes from Download to Check for ~2 seconds after clicking — needs Playwright screenshot comparison"
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe("Edge cases", () => {
|
|
411
|
+
afterEach(() => cleanup());
|
|
412
|
+
|
|
413
|
+
test("null value renders without error", async () => {
|
|
414
|
+
const { queryByLabelText } = await render(Code, {
|
|
415
|
+
...default_props,
|
|
416
|
+
value: null
|
|
417
|
+
});
|
|
418
|
+
// null value with non-interactive should show empty state (no editor)
|
|
419
|
+
// but with interactive=true, editor is shown
|
|
420
|
+
expect(queryByLabelText("Code input container")).toBeTruthy();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("empty string value with interactive=true shows the editor", async () => {
|
|
424
|
+
const { getByLabelText } = await render(Code, {
|
|
425
|
+
...default_props,
|
|
426
|
+
value: "",
|
|
427
|
+
interactive: true
|
|
428
|
+
});
|
|
429
|
+
expect(getByLabelText("Code input container")).toBeTruthy();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("value with leading and trailing whitespace is displayed", async () => {
|
|
433
|
+
const { get_data } = await render(Code, {
|
|
434
|
+
...default_props,
|
|
435
|
+
value: " hello "
|
|
436
|
+
});
|
|
437
|
+
const data = await get_data();
|
|
438
|
+
expect(data.value).toBe(" hello ");
|
|
439
|
+
});
|
|
440
|
+
});
|
package/Download.test.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Download.svelte's language → file-extension mapping.
|
|
3
|
+
*
|
|
4
|
+
* Download.svelte is not a Gradio component so the tootils render() utility
|
|
5
|
+
* cannot mount it with props correctly (non-shared props are wrapped under a
|
|
6
|
+
* `props` key rather than spread at the top level). We therefore test the
|
|
7
|
+
* extension mapping through the full Code component, which is the realistic
|
|
8
|
+
* usage path anyway.
|
|
9
|
+
*/
|
|
10
|
+
import { test, describe, afterEach, expect } from "vitest";
|
|
11
|
+
import { cleanup, render } from "@self/tootils/render";
|
|
12
|
+
|
|
13
|
+
import Code from "./Index.svelte";
|
|
14
|
+
|
|
15
|
+
const base_props = {
|
|
16
|
+
value: "// code",
|
|
17
|
+
lines: 5,
|
|
18
|
+
interactive: false,
|
|
19
|
+
show_label: false,
|
|
20
|
+
label: "Code",
|
|
21
|
+
buttons: ["download"] as const
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function get_download_link(container: HTMLElement): HTMLAnchorElement {
|
|
25
|
+
// The Download component renders an <a download="file.ext"> anchor.
|
|
26
|
+
// No accessible name or role distinguishes it better than the download
|
|
27
|
+
// attribute itself, so querySelector is used as the last-resort query.
|
|
28
|
+
const link = container.querySelector<HTMLAnchorElement>("a[download]");
|
|
29
|
+
if (!link) throw new Error("No download link found in rendered output");
|
|
30
|
+
return link;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Download: language → file extension mapping", () => {
|
|
34
|
+
afterEach(() => cleanup());
|
|
35
|
+
|
|
36
|
+
const cases: Array<[string, string]> = [
|
|
37
|
+
["python", "py"],
|
|
38
|
+
["py", "py"],
|
|
39
|
+
["javascript", "js"],
|
|
40
|
+
["js", "js"],
|
|
41
|
+
["typescript", "ts"],
|
|
42
|
+
["ts", "ts"],
|
|
43
|
+
["shell", "sh"],
|
|
44
|
+
["sh", "sh"],
|
|
45
|
+
["markdown", "md"],
|
|
46
|
+
["md", "md"],
|
|
47
|
+
["json", "json"],
|
|
48
|
+
["html", "html"],
|
|
49
|
+
["css", "css"],
|
|
50
|
+
["yaml", "yaml"],
|
|
51
|
+
["yml", "yml"],
|
|
52
|
+
["dockerfile", "dockerfile"],
|
|
53
|
+
["latex", "tex"],
|
|
54
|
+
["r", "r"],
|
|
55
|
+
["c", "c"],
|
|
56
|
+
["cpp", "cpp"]
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const [language, expected_ext] of cases) {
|
|
60
|
+
test(`language='${language}' produces download filename 'file.${expected_ext}'`, async () => {
|
|
61
|
+
const { container } = await render(Code, {
|
|
62
|
+
...base_props,
|
|
63
|
+
language
|
|
64
|
+
});
|
|
65
|
+
const link = get_download_link(container);
|
|
66
|
+
expect(link).toHaveAttribute("download", `file.${expected_ext}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("unknown language falls back to 'file.txt'", async () => {
|
|
71
|
+
const { container } = await render(Code, {
|
|
72
|
+
...base_props,
|
|
73
|
+
language: "brainfuck"
|
|
74
|
+
});
|
|
75
|
+
const link = get_download_link(container);
|
|
76
|
+
expect(link).toHaveAttribute("download", "file.txt");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("null language falls back to 'file.txt'", async () => {
|
|
80
|
+
const { container } = await render(Code, {
|
|
81
|
+
...base_props,
|
|
82
|
+
language: null
|
|
83
|
+
});
|
|
84
|
+
const link = get_download_link(container);
|
|
85
|
+
expect(link).toHaveAttribute("download", "file.txt");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("Download: link structure", () => {
|
|
90
|
+
afterEach(() => cleanup());
|
|
91
|
+
|
|
92
|
+
test("download link href is a blob URL", async () => {
|
|
93
|
+
const { container } = await render(Code, {
|
|
94
|
+
...base_props,
|
|
95
|
+
language: "python"
|
|
96
|
+
});
|
|
97
|
+
const link = get_download_link(container);
|
|
98
|
+
expect(link.getAttribute("href")).toMatch(/^blob:/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("download link is visible", async () => {
|
|
102
|
+
const { container } = await render(Code, {
|
|
103
|
+
...base_props,
|
|
104
|
+
language: "python"
|
|
105
|
+
});
|
|
106
|
+
const link = get_download_link(container);
|
|
107
|
+
expect(link).toBeVisible();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("download link contains a button with label 'Download'", async () => {
|
|
111
|
+
const { getByLabelText } = await render(Code, {
|
|
112
|
+
...base_props,
|
|
113
|
+
language: "python"
|
|
114
|
+
});
|
|
115
|
+
expect(getByLabelText("Download")).toBeTruthy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test.todo(
|
|
119
|
+
"VISUAL: clicking the download link changes the icon from Download to Check for ~2 seconds — needs Playwright screenshot comparison"
|
|
120
|
+
);
|
|
121
|
+
});
|
package/Index.svelte
CHANGED
package/dist/Index.svelte
CHANGED
package/dist/shared/Copy.svelte
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradio/code",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.7",
|
|
4
4
|
"description": "Gradio UI packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"cm6-theme-basic-dark": "^0.2.0",
|
|
28
28
|
"cm6-theme-basic-light": "^0.2.0",
|
|
29
29
|
"codemirror": "^6.0.2",
|
|
30
|
-
"@gradio/atoms": "^0.23.
|
|
31
|
-
"@gradio/
|
|
30
|
+
"@gradio/atoms": "^0.23.1",
|
|
31
|
+
"@gradio/statustracker": "^0.14.0",
|
|
32
32
|
"@gradio/icons": "^0.15.1",
|
|
33
33
|
"@gradio/upload": "^0.17.8",
|
|
34
|
-
"@gradio/
|
|
34
|
+
"@gradio/utils": "^0.12.2"
|
|
35
35
|
},
|
|
36
36
|
"main_changeset": true,
|
|
37
37
|
"main": "./Index.svelte",
|
package/shared/Copy.svelte
CHANGED
package/shared/Download.svelte
CHANGED