Skip to content

Commit a17d741

Browse files
authored
Merge pull request #669 from fractal-analytics-platform/task-collection-form-data
Used form data in tasks collection endpoint
2 parents b3bd2f1 + 177dc1a commit a17d741

File tree

6 files changed

+127
-57
lines changed

6 files changed

+127
-57
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
*Note: Numbers like (\#123) point to closed Pull Requests on the fractal-web repository.*
22

3+
# Unreleased
4+
5+
* Used form data in tasks collection endpoint (\#669);
6+
37
# 1.11.2
48

59
* Removed usage of `cache_dir` field (\#667);

__tests__/v2/TaskCollection.test.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, beforeEach, expect, vi } from 'vitest';
1+
import { describe, it, beforeEach, expect, vi, beforeAll } from 'vitest';
22
import { render, screen } from '@testing-library/svelte';
33
import userEvent from '@testing-library/user-event';
44

@@ -21,6 +21,20 @@ const mockedUser = {
2121
import TaskCollection from '../../src/lib/components/v2/tasks/TaskCollection.svelte';
2222

2323
describe('TaskCollection', () => {
24+
beforeAll(() => {
25+
expect.extend({
26+
toBeFormDataWith(received, expectedProperties) {
27+
const pass = received instanceof FormData;
28+
const receivedObject = pass ? Object.fromEntries(received.entries()) : {};
29+
expect(receivedObject).toMatchObject(expectedProperties);
30+
return {
31+
message: () => `expected ${received} to be FormData`,
32+
pass
33+
};
34+
}
35+
});
36+
});
37+
2438
beforeEach(() => {
2539
fetch.mockClear();
2640
});
@@ -54,7 +68,7 @@ describe('TaskCollection', () => {
5468
expect(fetch).toHaveBeenCalledWith(
5569
'/api/v2/task/collect/pip?private=false&user_group_id=2',
5670
expect.objectContaining({
57-
body: JSON.stringify({ package: 'test-task' })
71+
body: expect.toBeFormDataWith({ package: 'test-task' })
5872
})
5973
);
6074
});
@@ -88,7 +102,7 @@ describe('TaskCollection', () => {
88102
expect(fetch).toHaveBeenCalledWith(
89103
'/api/v2/task/collect/pip?private=true',
90104
expect.objectContaining({
91-
body: JSON.stringify({ package: 'test-task' })
105+
body: expect.toBeFormDataWith({ package: 'test-task' })
92106
})
93107
);
94108
});

playwright.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default defineConfig({
107107

108108
webServer: [
109109
{
110-
command: './tests/start-test-server.sh 2.9.2',
110+
command: './tests/start-test-server.sh 2.10.0a0',
111111
port: 8000,
112112
waitForPort: true,
113113
stdout: 'pipe',

src/lib/components/v2/tasks/TaskCollection.svelte

Lines changed: 79 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { env } from '$env/dynamic/public';
33
import { onDestroy, onMount } from 'svelte';
44
import TaskGroupActivityLogsModal from '$lib/components/v2/tasks/TaskGroupActivityLogsModal.svelte';
5-
import { replaceEmptyStrings } from '$lib/common/component_utilities';
65
import { FormErrorHandler } from '$lib/common/errors';
76
import TaskGroupSelector from './TaskGroupSelector.svelte';
87
import {
@@ -34,6 +33,12 @@
3433
let pinnedPackageVersions = [];
3534
let privateTask = false;
3635
let selectedGroup = null;
36+
37+
/** @type {FileList|null} */
38+
let wheelFiles = null;
39+
/** @type {HTMLInputElement|undefined} */
40+
let wheelFileInput = undefined;
41+
3742
/** @type {TaskGroupActivityLogsModal} */
3843
let taskGroupActivitiesLogsModal;
3944
/** @type {number|null} */
@@ -98,22 +103,34 @@
98103
async function handleTaskCollection() {
99104
formErrorHandler.clearErrors();
100105
101-
const headers = new Headers();
102-
headers.append('Content-Type', 'application/json');
106+
if (packageType === 'local' && (wheelFiles === null || wheelFiles.length === 0)) {
107+
formErrorHandler.addValidationError('file', 'Required field');
108+
return;
109+
}
103110
104-
const requestData = {
105-
package: python_package,
106-
python_version,
107-
package_extras
108-
};
111+
const formData = new FormData();
109112
110113
if (packageType === 'pypi') {
111-
requestData.package_version = package_version;
114+
formData.append('package', python_package);
115+
} else if (wheelFiles?.length === 1) {
116+
formData.append('file', wheelFiles[0]);
117+
}
118+
119+
if (python_version) {
120+
formData.append('python_version', python_version);
121+
}
122+
123+
if (package_extras) {
124+
formData.append('package_extras', package_extras);
125+
}
126+
127+
if (packageType === 'pypi' && package_version) {
128+
formData.append('package_version', package_version);
112129
}
113130
114131
const ppv = getPinnedPackageVersionsMap();
115132
if (ppv) {
116-
requestData.pinned_package_versions = ppv;
133+
formData.append('pinned_package_versions', JSON.stringify(ppv));
117134
}
118135
119136
let url = `/api/v2/task/collect/pip?private=${privateTask}`;
@@ -125,9 +142,9 @@
125142
const response = await fetch(url, {
126143
method: 'POST',
127144
credentials: 'include',
128-
headers: headers,
129-
body: JSON.stringify(requestData, replaceEmptyStrings)
145+
body: formData
130146
});
147+
131148
taskCollectionInProgress = false;
132149
133150
if (response.ok) {
@@ -140,6 +157,7 @@
140157
python_version = '';
141158
package_extras = '';
142159
pinnedPackageVersions = [];
160+
clearWheelFileUpload();
143161
} else {
144162
console.error('Task collection request failed');
145163
await formErrorHandler.handleErrorResponse(response);
@@ -216,6 +234,14 @@
216234
);
217235
}
218236
237+
function clearWheelFileUpload() {
238+
wheelFiles = null;
239+
if (wheelFileInput) {
240+
wheelFileInput.value = '';
241+
}
242+
formErrorHandler.removeValidationError('file');
243+
}
244+
219245
onDestroy(() => {
220246
clearTimeout(updateTasksCollectionTimeout);
221247
});
@@ -226,34 +252,50 @@
226252
<div>
227253
<form on:submit|preventDefault={handleTaskCollection}>
228254
<div class="row">
229-
<div
230-
class="mb-2"
231-
class:col-md-6={packageType === 'pypi'}
232-
class:col-md-12={packageType === 'local'}
233-
>
234-
<div class="input-group has-validation">
235-
<div class="input-group-text">
236-
<label class="font-monospace" for="package">Package</label>
255+
{#if packageType === 'pypi'}
256+
<div class="mb-2 col-md-6">
257+
<div class="input-group has-validation">
258+
<div class="input-group-text">
259+
<label class="font-monospace" for="package">Package</label>
260+
</div>
261+
<input
262+
name="package"
263+
id="package"
264+
type="text"
265+
class="form-control"
266+
required
267+
class:is-invalid={$validationErrors['package']}
268+
bind:value={python_package}
269+
/>
270+
<span class="invalid-feedback">{$validationErrors['package']}</span>
237271
</div>
238-
<input
239-
name="package"
240-
id="package"
241-
type="text"
242-
class="form-control"
243-
required
244-
class:is-invalid={$validationErrors['package']}
245-
bind:value={python_package}
246-
/>
247-
<span class="invalid-feedback">{$validationErrors['package']}</span>
272+
<div class="form-text">The name of a package published on PyPI</div>
248273
</div>
249-
<div class="form-text">
250-
{#if packageType === 'pypi'}
251-
The name of a package published on PyPI
252-
{:else}
253-
The full path to a wheel file
254-
{/if}
274+
{:else if packageType === 'local'}
275+
<div class="mb-2 col-md-6">
276+
<div class="input-group has-validation">
277+
<label for="wheelFile" class="input-group-text">
278+
<i class="bi bi-file-earmark-arrow-up" /> &nbsp; Upload a wheel file
279+
</label>
280+
<input
281+
class="form-control"
282+
accept=".whl"
283+
type="file"
284+
name="wheelFile"
285+
id="wheelFile"
286+
bind:this={wheelFileInput}
287+
bind:files={wheelFiles}
288+
class:is-invalid={$validationErrors['file']}
289+
/>
290+
{#if wheelFiles && wheelFiles.length > 0}
291+
<button class="btn btn-outline-secondary" on:click={clearWheelFileUpload}>
292+
Clear
293+
</button>
294+
{/if}
295+
<span class="invalid-feedback">{$validationErrors['file']}</span>
296+
</div>
255297
</div>
256-
</div>
298+
{/if}
257299
{#if packageType === 'pypi'}
258300
<div class="col-md-6 mb-2">
259301
<div class="input-group has-validation">

src/routes/proxy.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export function createPostProxy(path) {
3333
method: 'POST',
3434
credentials: 'include',
3535
headers: filterHeaders(request.headers),
36-
body: JSON.stringify(await request.json())
36+
body: request.body,
37+
// To avoid error "RequestInit: duplex option is required when sending a body"
38+
// @ts-ignore, not standard, but supported by undici; enable re-streaming of request
39+
duplex: 'half'
3740
});
3841
} catch (err) {
3942
logger.debug(err);
@@ -53,7 +56,9 @@ export function createPatchProxy(path) {
5356
method: 'PATCH',
5457
credentials: 'include',
5558
headers: filterHeaders(request.headers),
56-
body: JSON.stringify(await request.json())
59+
body: request.body,
60+
// @ts-ignore, not standard, but supported by undici; enable re-streaming of request
61+
duplex: 'half'
5762
});
5863
} catch (err) {
5964
logger.debug(err);

tests/v2/collect_mock_tasks.setup.js

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,6 @@ test('Collect mock tasks [v2]', async ({ page, request }) => {
1515
await waitPageLoading(page);
1616
});
1717

18-
await test.step('Attempt to collect tasks with invalid path', async () => {
19-
await page.getByText('Local', { exact: true }).click();
20-
await page.getByRole('textbox', { name: 'Package', exact: true }).fill('./foo');
21-
await page.getByRole('button', { name: 'Collect', exact: true }).click();
22-
await expect(
23-
page.getByText('Local-package path must be a wheel file (given ./foo).')
24-
).toBeVisible();
25-
});
26-
2718
if ((await page.getByRole('table').last().getByText('MIP_compound').count()) > 0) {
2819
console.warn('WARNING: Mock tasks V2 already collected. Skipping tasks collection');
2920
return;
@@ -36,16 +27,25 @@ test('Collect mock tasks [v2]', async ({ page, request }) => {
3627
const response = await request.get(tasksMockWheelUrl);
3728
expect(response.status()).toEqual(200);
3829
const body = await response.body();
39-
const tmpDir = path.resolve(os.tmpdir(), 'playwright');
40-
if (!fs.existsSync(tmpDir)) {
41-
fs.mkdirSync(tmpDir);
30+
const tasksMockWheelFolder = path.resolve(os.tmpdir(), 'playwright');
31+
if (!fs.existsSync(tasksMockWheelFolder)) {
32+
fs.mkdirSync(tasksMockWheelFolder);
4233
}
43-
tasksMockWheelFile = path.resolve(tmpDir, 'fractal_tasks_mock-0.0.1-py3-none-any.whl');
34+
tasksMockWheelFile = path.resolve(
35+
tasksMockWheelFolder,
36+
'fractal_tasks_mock-0.0.1-py3-none-any.whl'
37+
);
4438
fs.writeFileSync(tasksMockWheelFile, body);
4539
});
4640

4741
await test.step('Collect mock tasks', async () => {
48-
await page.getByRole('textbox', { name: 'Package', exact: true }).fill(tasksMockWheelFile);
42+
await page.getByText('Local', { exact: true }).click();
43+
44+
const fileChooserPromise = page.waitForEvent('filechooser');
45+
await page.getByText('Upload a wheel file', { exact: true }).click();
46+
const fileChooser = await fileChooserPromise;
47+
await fileChooser.setFiles(tasksMockWheelFile);
48+
4949
await page.getByRole('button', { name: 'Collect', exact: true }).click();
5050

5151
// Wait for Task collections table
@@ -69,4 +69,9 @@ test('Collect mock tasks [v2]', async ({ page, request }) => {
6969
await test.step('Cleanup temporary wheel file', async () => {
7070
fs.rmSync(tasksMockWheelFile);
7171
});
72+
73+
await test.step('Attempt to collect tasks without a wheel file', async () => {
74+
await page.getByRole('button', { name: 'Collect', exact: true }).click();
75+
await expect(page.getByText('Required field')).toBeVisible();
76+
});
7277
});

0 commit comments

Comments
 (0)