@widgetic/creator 0.3.76 → 0.3.77
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/dist/CreatorApp.svelte +52 -13
- package/dist/components/BrandKitPanel.svelte +5 -3
- package/dist/utils/widgeticChatUpload.d.ts +10 -0
- package/dist/utils/widgeticChatUpload.js +57 -2
- package/dist/utils/widgeticChatUpload.test.d.ts +1 -0
- package/dist/utils/widgeticChatUpload.test.js +257 -0
- package/package.json +1 -1
package/dist/CreatorApp.svelte
CHANGED
|
@@ -1091,7 +1091,7 @@
|
|
|
1091
1091
|
// so the backend can load multi-turn LLM history (Aider-style incremental edits).
|
|
1092
1092
|
let currentChatConversationId: string | null = null;
|
|
1093
1093
|
let brandKitEnabled = false;
|
|
1094
|
-
let widgetLanguage = '
|
|
1094
|
+
let widgetLanguage: 'svelte' | 'react' = 'react';
|
|
1095
1095
|
let librariesEnabled = false;
|
|
1096
1096
|
let brandPrimary = '#314F2F';
|
|
1097
1097
|
let brandSecondary = '#7BA668';
|
|
@@ -7586,6 +7586,9 @@
|
|
|
7586
7586
|
...(lastCommitId ? { hasGeneratedCode: true } : {}),
|
|
7587
7587
|
});
|
|
7588
7588
|
if (lastCommitId) hasGeneratedCode = true;
|
|
7589
|
+
if (widget.id === focusedPanelWidgetId) {
|
|
7590
|
+
widgetLanguage = languageToShow(widget.id, widgetLanguage === 'svelte' ? 'svelte' : 'react');
|
|
7591
|
+
}
|
|
7589
7592
|
// Fetch history whenever the widget has code (generated or published).
|
|
7590
7593
|
// Gating on `lastPublishedVersion` alone meant a widget that had only
|
|
7591
7594
|
// ever been generated never loaded its history — so its version picker
|
|
@@ -8965,6 +8968,47 @@ Requirements:
|
|
|
8965
8968
|
}
|
|
8966
8969
|
}
|
|
8967
8970
|
|
|
8971
|
+
function generatedLanguageStorageKey(widgetId: string): string {
|
|
8972
|
+
return `widgetic-generated-language:${widgetId}`;
|
|
8973
|
+
}
|
|
8974
|
+
|
|
8975
|
+
function readGeneratedLanguage(widgetId: string): 'svelte' | 'react' | null {
|
|
8976
|
+
try {
|
|
8977
|
+
const value = localStorage.getItem(generatedLanguageStorageKey(widgetId));
|
|
8978
|
+
return value === 'svelte' || value === 'react' ? value : null;
|
|
8979
|
+
} catch (error) {
|
|
8980
|
+
console.warn('[WidgetCreator] Could not read generated language', error);
|
|
8981
|
+
return null;
|
|
8982
|
+
}
|
|
8983
|
+
}
|
|
8984
|
+
|
|
8985
|
+
function rememberGeneratedLanguage(widgetId: string, language: 'svelte' | 'react'): void {
|
|
8986
|
+
try {
|
|
8987
|
+
localStorage.setItem(generatedLanguageStorageKey(widgetId), language);
|
|
8988
|
+
} catch (error) {
|
|
8989
|
+
console.warn('[WidgetCreator] Could not store generated language', error);
|
|
8990
|
+
}
|
|
8991
|
+
}
|
|
8992
|
+
|
|
8993
|
+
function widgetHasGeneratedCode(widgetId: string): boolean {
|
|
8994
|
+
const panel = getPanelState(widgetId);
|
|
8995
|
+
const widget = getWidgetById(widgetId);
|
|
8996
|
+
return !!(
|
|
8997
|
+
panel.lastCommitId
|
|
8998
|
+
|| loadCommitIdForWidget(widgetId)
|
|
8999
|
+
|| panel.lastPublishedVersion != null
|
|
9000
|
+
|| resolvePublishedVersionFromWidget(widget) !== null
|
|
9001
|
+
);
|
|
9002
|
+
}
|
|
9003
|
+
|
|
9004
|
+
/** A widget that already has code stays on the language it was generated with. Older widgets are Svelte. */
|
|
9005
|
+
function languageToShow(widgetId: string, storedLanguage: 'svelte' | 'react' | null): 'svelte' | 'react' {
|
|
9006
|
+
if (widgetHasGeneratedCode(widgetId)) {
|
|
9007
|
+
return readGeneratedLanguage(widgetId) ?? 'svelte';
|
|
9008
|
+
}
|
|
9009
|
+
return storedLanguage ?? 'react';
|
|
9010
|
+
}
|
|
9011
|
+
|
|
8968
9012
|
function restoreGenerationOptions(widgetId: string): void {
|
|
8969
9013
|
const inMemory = generationOptionsMemory[widgetId];
|
|
8970
9014
|
let stored: Partial<RememberedGenerationOptions> | null = null;
|
|
@@ -8977,7 +9021,8 @@ Requirements:
|
|
|
8977
9021
|
}
|
|
8978
9022
|
}
|
|
8979
9023
|
const options = inMemory || stored;
|
|
8980
|
-
|
|
9024
|
+
const storedLanguage = options?.language === 'svelte' || options?.language === 'react' ? options.language : null;
|
|
9025
|
+
widgetLanguage = languageToShow(widgetId, storedLanguage);
|
|
8981
9026
|
librariesEnabled = !!options?.librariesEnabled;
|
|
8982
9027
|
brandKitLibraries = Array.isArray(options?.libraries) ? [...options.libraries] : [];
|
|
8983
9028
|
brandKitEnabled = !!options?.brandKitEnabled;
|
|
@@ -8991,18 +9036,12 @@ Requirements:
|
|
|
8991
9036
|
}
|
|
8992
9037
|
|
|
8993
9038
|
function languageForGeneration(widgetId: string): 'svelte' | 'react' {
|
|
8994
|
-
const
|
|
8995
|
-
const
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
try {
|
|
8999
|
-
const saved = localStorage.getItem(languageStorageKey(widgetId));
|
|
9000
|
-
language = saved === 'react' ? 'react' : 'svelte';
|
|
9001
|
-
} catch (error) {
|
|
9002
|
-
console.warn('[WidgetCreator] Could not read widget language', error);
|
|
9003
|
-
}
|
|
9004
|
-
}
|
|
9039
|
+
const hasCode = widgetHasGeneratedCode(widgetId);
|
|
9040
|
+
const language: 'svelte' | 'react' = hasCode
|
|
9041
|
+
? (readGeneratedLanguage(widgetId) ?? 'svelte')
|
|
9042
|
+
: (widgetLanguage === 'svelte' ? 'svelte' : 'react');
|
|
9005
9043
|
widgetLanguage = language;
|
|
9044
|
+
rememberGeneratedLanguage(widgetId, language);
|
|
9006
9045
|
rememberGenerationOptions(widgetId);
|
|
9007
9046
|
return language;
|
|
9008
9047
|
}
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
libraryUploads = $bindable([] as { name: string; content: string; encoding: string }[]),
|
|
56
56
|
fontUploads = $bindable([] as { name: string; content: string; encoding: string }[]),
|
|
57
57
|
showLanguage = false,
|
|
58
|
-
language = $bindable('
|
|
58
|
+
language = $bindable('react'),
|
|
59
59
|
languageLocked = false,
|
|
60
60
|
}: Props = $props();
|
|
61
61
|
|
|
@@ -317,10 +317,12 @@
|
|
|
317
317
|
</HoverTooltip>
|
|
318
318
|
{/if}
|
|
319
319
|
{/if}
|
|
320
|
+
{#if showPresetLibraries}
|
|
320
321
|
<label class="generation-option-toggle flex h-10 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-2 text-sm {librariesEnabled ? 'border-[#314F2F] bg-[#314F2F] text-white' : 'border-gray-300 bg-white text-gray-800'}">
|
|
321
322
|
<input class="generation-option-check cursor-pointer" type="checkbox" checked={librariesEnabled} onchange={onLibrariesChange} />
|
|
322
323
|
Libraries
|
|
323
324
|
</label>
|
|
325
|
+
{/if}
|
|
324
326
|
{#if librariesEnabled && showPresetLibraries}
|
|
325
327
|
{#each libraryChoices as choice (choice.id)}
|
|
326
328
|
<label class="generation-library flex items-center gap-1 text-xs text-gray-700">
|
|
@@ -334,7 +336,7 @@
|
|
|
334
336
|
</label>
|
|
335
337
|
{/each}
|
|
336
338
|
{/if}
|
|
337
|
-
{#if librariesEnabled}
|
|
339
|
+
{#if librariesEnabled && showPresetLibraries}
|
|
338
340
|
<HoverTooltip class="inline-flex shrink-0" text="Add Library">
|
|
339
341
|
<button
|
|
340
342
|
type="button"
|
|
@@ -345,7 +347,7 @@
|
|
|
345
347
|
</HoverTooltip>
|
|
346
348
|
{/if}
|
|
347
349
|
</div>
|
|
348
|
-
{#if librariesEnabled}
|
|
350
|
+
{#if librariesEnabled && showPresetLibraries}
|
|
349
351
|
{#each libraryRows as row (row.id)}
|
|
350
352
|
<div class="generation-source-row flex items-center gap-1">
|
|
351
353
|
<FileUrlSourceControl
|
|
@@ -19,6 +19,16 @@ export type ChatUploadOptions = {
|
|
|
19
19
|
onProgress?: (percentage: number) => void;
|
|
20
20
|
abortSignal?: AbortSignal;
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* POST …/complete. A 409 `TUS_UPLOAD_BUSY` means another request still has the upload (the chunk that
|
|
24
|
+
* just finished, or a retried one), so ask again after a short pause. Every other answer is final: a
|
|
25
|
+
* 409 `TUS_UPLOAD_INCOMPLETE` means chunks are missing, and asking again cannot bring them.
|
|
26
|
+
* Returns the last answer; the caller tells success from failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function requestTusComplete(url: string, init: RequestInit, options?: {
|
|
29
|
+
retryDelaysMs?: readonly number[];
|
|
30
|
+
abortSignal?: AbortSignal;
|
|
31
|
+
}): Promise<Response>;
|
|
22
32
|
export declare function shouldUseTusForChatUpload(file: File): boolean;
|
|
23
33
|
/**
|
|
24
34
|
* Upload a chat attachment via multipart (≤10MB) or TUS (>10MB).
|
|
@@ -2,12 +2,62 @@ import * as tus from 'tus-js-client';
|
|
|
2
2
|
/** Matches api-gateway MULTIPART_MAX_UPLOAD_BYTES. */
|
|
3
3
|
export const CHAT_MULTIPART_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
4
|
const DEFAULT_TUS_CHUNK_SIZE = 5 * 1024 * 1024;
|
|
5
|
+
/** The `error.code` api-gateway answers `POST …/complete` with (HTTP 409) while another request still has the upload. */
|
|
6
|
+
const TUS_UPLOAD_BUSY_CODE = 'TUS_UPLOAD_BUSY';
|
|
7
|
+
/** Pauses before asking again after a "busy" answer: 0.5, 1, 2, 4 and 8 seconds, about 15 seconds in all. */
|
|
8
|
+
const COMPLETE_BUSY_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000];
|
|
5
9
|
function parseErrorMessage(response, fallback) {
|
|
6
10
|
return response
|
|
7
11
|
.json()
|
|
8
12
|
.then((json) => json?.message || json?.error?.message || fallback)
|
|
9
13
|
.catch(() => fallback);
|
|
10
14
|
}
|
|
15
|
+
/** The `error.code` of a failed answer, or undefined when the body is not the API's error envelope. */
|
|
16
|
+
function readErrorCode(response) {
|
|
17
|
+
return response
|
|
18
|
+
.json()
|
|
19
|
+
.then((json) => (typeof json?.error?.code === 'string' ? json.error.code : undefined))
|
|
20
|
+
.catch(() => undefined);
|
|
21
|
+
}
|
|
22
|
+
/** Resolves after `delayMs`, or at once when the upload is cancelled meanwhile. */
|
|
23
|
+
function waitBeforeRetry(delayMs, abortSignal) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
if (abortSignal?.aborted) {
|
|
26
|
+
resolve();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const finish = () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
abortSignal?.removeEventListener('abort', finish);
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(finish, delayMs);
|
|
35
|
+
abortSignal?.addEventListener('abort', finish, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* POST …/complete. A 409 `TUS_UPLOAD_BUSY` means another request still has the upload (the chunk that
|
|
40
|
+
* just finished, or a retried one), so ask again after a short pause. Every other answer is final: a
|
|
41
|
+
* 409 `TUS_UPLOAD_INCOMPLETE` means chunks are missing, and asking again cannot bring them.
|
|
42
|
+
* Returns the last answer; the caller tells success from failure.
|
|
43
|
+
*/
|
|
44
|
+
export async function requestTusComplete(url, init, options = {}) {
|
|
45
|
+
const retryDelaysMs = options.retryDelaysMs ?? COMPLETE_BUSY_RETRY_DELAYS_MS;
|
|
46
|
+
let retriesDone = 0;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const response = await fetch(url, init);
|
|
49
|
+
if (response.status !== 409 || retriesDone >= retryDelaysMs.length)
|
|
50
|
+
return response;
|
|
51
|
+
// Read a copy, so the caller can still read the message of an answer that turns out to be final.
|
|
52
|
+
if ((await readErrorCode(response.clone())) !== TUS_UPLOAD_BUSY_CODE)
|
|
53
|
+
return response;
|
|
54
|
+
console.warn(`[ChatUpload] Upload is busy; asking again in ${retryDelaysMs[retriesDone]} ms (retry ${retriesDone + 1} of ${retryDelaysMs.length})`);
|
|
55
|
+
await waitBeforeRetry(retryDelaysMs[retriesDone], options.abortSignal);
|
|
56
|
+
if (options.abortSignal?.aborted)
|
|
57
|
+
return response;
|
|
58
|
+
retriesDone += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
11
61
|
function mapUploadResponse(data, file) {
|
|
12
62
|
const url = data.url || '';
|
|
13
63
|
if (!url) {
|
|
@@ -101,14 +151,19 @@ function uploadTus(file, config, options = {}) {
|
|
|
101
151
|
return;
|
|
102
152
|
}
|
|
103
153
|
try {
|
|
104
|
-
const completeResponse = await
|
|
154
|
+
const completeResponse = await requestTusComplete(`${endpoint}/${tusId}/complete`, {
|
|
105
155
|
method: 'POST',
|
|
106
156
|
headers: {
|
|
107
157
|
Authorization: `Bearer ${token}`,
|
|
108
158
|
'Content-Type': 'application/json',
|
|
109
159
|
},
|
|
110
|
-
});
|
|
160
|
+
}, { abortSignal: options.abortSignal });
|
|
111
161
|
if (!completeResponse.ok) {
|
|
162
|
+
// Cancelled while waiting to ask again: say so, instead of repeating the server's "busy" answer.
|
|
163
|
+
if (options.abortSignal?.aborted) {
|
|
164
|
+
reject(new Error('Upload cancelled.'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
112
167
|
reject(new Error(await parseErrorMessage(completeResponse, `Complete failed (${completeResponse.status})`)));
|
|
113
168
|
return;
|
|
114
169
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CHAT_MULTIPART_MAX_BYTES, requestTusComplete, uploadChatFileToWidgeticApi } from './widgeticChatUpload';
|
|
3
|
+
// The real client opens connections and sends the file in chunks. This stand-in "finishes" the moment it is
|
|
4
|
+
// started, so each test only decides how the gateway answers `POST …/complete`.
|
|
5
|
+
vi.mock('tus-js-client', () => {
|
|
6
|
+
class Upload {
|
|
7
|
+
file;
|
|
8
|
+
options;
|
|
9
|
+
url = 'https://api.test/v1/uploads/tus/0b9e0f7e-6d2c-4a39-8d36-2f6a5f0c7a11~0123456789abcdef0123456789abcdef';
|
|
10
|
+
constructor(file, options) {
|
|
11
|
+
this.file = file;
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
findPreviousUploads() {
|
|
15
|
+
return Promise.resolve([]);
|
|
16
|
+
}
|
|
17
|
+
resumeFromPreviousUpload() { }
|
|
18
|
+
start() {
|
|
19
|
+
this.options.onSuccess();
|
|
20
|
+
}
|
|
21
|
+
abort() {
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { Upload };
|
|
26
|
+
});
|
|
27
|
+
const COMPLETE_URL = 'https://api.test/v1/uploads/tus/0b9e0f7e-6d2c-4a39-8d36-2f6a5f0c7a11~0123456789abcdef0123456789abcdef/complete';
|
|
28
|
+
const BUSY_MESSAGE = 'The upload is in use by another request. Try again in a moment.';
|
|
29
|
+
const INCOMPLETE_MESSAGE = 'Upload is not complete: 4 of 10 bytes received. Send the remaining chunks, then complete the upload.';
|
|
30
|
+
const config = { apiBasePath: 'https://api.test/v1/', accessToken: 'Bearer test-token' };
|
|
31
|
+
/** A file big enough to be sent with TUS, without allocating its bytes (the stand-in never reads them). */
|
|
32
|
+
function largeFile() {
|
|
33
|
+
const file = new File(['0123456789'], 'clip.mp4', { type: 'video/mp4' });
|
|
34
|
+
Object.defineProperty(file, 'size', { value: CHAT_MULTIPART_MAX_BYTES + 1 });
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
function jsonResponse(status, body) {
|
|
38
|
+
return new Response(JSON.stringify(body), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** What api-gateway answers while another request still has the upload. */
|
|
44
|
+
const busyAnswer = () => jsonResponse(409, {
|
|
45
|
+
success: false,
|
|
46
|
+
message: BUSY_MESSAGE,
|
|
47
|
+
error: { code: 'TUS_UPLOAD_BUSY', message: BUSY_MESSAGE }
|
|
48
|
+
});
|
|
49
|
+
/** What api-gateway answers while chunks are missing: asking again cannot help. */
|
|
50
|
+
const incompleteAnswer = () => jsonResponse(409, {
|
|
51
|
+
success: false,
|
|
52
|
+
message: INCOMPLETE_MESSAGE,
|
|
53
|
+
error: { code: 'TUS_UPLOAD_INCOMPLETE', message: INCOMPLETE_MESSAGE }
|
|
54
|
+
});
|
|
55
|
+
const completedAnswer = () => jsonResponse(200, {
|
|
56
|
+
success: true,
|
|
57
|
+
data: {
|
|
58
|
+
id: 'file_1',
|
|
59
|
+
url: 'https://cdn.test/clip.mp4',
|
|
60
|
+
file_name: 'clip.mp4',
|
|
61
|
+
file_size: 10,
|
|
62
|
+
mime_type: 'video/mp4'
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const fetchMock = vi.fn();
|
|
66
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
67
|
+
/** Lets every pause end at once and records how long each one was asked to be. */
|
|
68
|
+
function skipPauses() {
|
|
69
|
+
const requestedPauses = [];
|
|
70
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler, delayMs) => {
|
|
71
|
+
requestedPauses.push(delayMs ?? 0);
|
|
72
|
+
return realSetTimeout(handler, 0);
|
|
73
|
+
}));
|
|
74
|
+
return requestedPauses;
|
|
75
|
+
}
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
fetchMock.mockReset();
|
|
78
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
79
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
80
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
81
|
+
});
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.restoreAllMocks();
|
|
84
|
+
vi.unstubAllGlobals();
|
|
85
|
+
});
|
|
86
|
+
describe('requestTusComplete', () => {
|
|
87
|
+
const init = { method: 'POST', headers: { Authorization: 'Bearer test-token' } };
|
|
88
|
+
it('asks once when the first answer is a success', async () => {
|
|
89
|
+
fetchMock.mockResolvedValueOnce(completedAnswer());
|
|
90
|
+
const response = await requestTusComplete(COMPLETE_URL, init);
|
|
91
|
+
expect(response.ok).toBe(true);
|
|
92
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledWith(COMPLETE_URL, init);
|
|
94
|
+
});
|
|
95
|
+
it('asks again after a "busy" answer and returns the answer that follows', async () => {
|
|
96
|
+
fetchMock
|
|
97
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
98
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
99
|
+
.mockResolvedValueOnce(completedAnswer());
|
|
100
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
101
|
+
expect(response.ok).toBe(true);
|
|
102
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
103
|
+
});
|
|
104
|
+
it('pauses for each given delay, in order, before it asks again', async () => {
|
|
105
|
+
fetchMock
|
|
106
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
107
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
108
|
+
.mockResolvedValueOnce(completedAnswer());
|
|
109
|
+
const requestedPauses = skipPauses();
|
|
110
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [1111, 2222, 3333] });
|
|
111
|
+
expect(requestedPauses.filter((delayMs) => delayMs > 1000)).toEqual([1111, 2222]);
|
|
112
|
+
});
|
|
113
|
+
it('gives up after the last delay and returns the "busy" answer, message still readable', async () => {
|
|
114
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
115
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0] });
|
|
116
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
117
|
+
expect(response.status).toBe(409);
|
|
118
|
+
expect(await response.json()).toMatchObject({
|
|
119
|
+
error: { code: 'TUS_UPLOAD_BUSY', message: BUSY_MESSAGE }
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
it('does not ask again when chunks are missing (409 TUS_UPLOAD_INCOMPLETE)', async () => {
|
|
123
|
+
fetchMock.mockResolvedValueOnce(incompleteAnswer());
|
|
124
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
125
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(response.status).toBe(409);
|
|
127
|
+
expect(await response.json()).toMatchObject({ error: { code: 'TUS_UPLOAD_INCOMPLETE' } });
|
|
128
|
+
});
|
|
129
|
+
it('does not ask again when a 409 carries another code', async () => {
|
|
130
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(409, { success: false, error: { code: 'SOMETHING_ELSE' } }));
|
|
131
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
132
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
it('does not ask again when the "busy" code comes with another status', async () => {
|
|
135
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(503, { success: false, error: { code: 'TUS_UPLOAD_BUSY' } }));
|
|
136
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
137
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
it('does not ask again when a 409 has no JSON body', async () => {
|
|
140
|
+
fetchMock.mockResolvedValueOnce(new Response('<html>Conflict</html>', { status: 409 }));
|
|
141
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
142
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
143
|
+
});
|
|
144
|
+
it.each([
|
|
145
|
+
[404, 'TUS_UPLOAD_NOT_FOUND'],
|
|
146
|
+
[413, 'QUOTA_STORAGE_EXCEEDED'],
|
|
147
|
+
[415, 'UPLOAD_UNSUPPORTED_TYPE'],
|
|
148
|
+
[503, 'TUS_DISABLED']
|
|
149
|
+
])('does not ask again after a %i %s', async (status, code) => {
|
|
150
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(status, { success: false, error: { code } }));
|
|
151
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
152
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
153
|
+
expect(response.status).toBe(status);
|
|
154
|
+
});
|
|
155
|
+
it('stops waiting when the upload is cancelled during a pause, and does not ask again', async () => {
|
|
156
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
// A pause long enough that only the cancel can end it.
|
|
159
|
+
const pauseStarted = new Promise((resolve) => {
|
|
160
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler, delayMs) => {
|
|
161
|
+
if (delayMs === 600_000)
|
|
162
|
+
resolve();
|
|
163
|
+
return realSetTimeout(handler, delayMs);
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
const answer = requestTusComplete(COMPLETE_URL, init, {
|
|
167
|
+
retryDelaysMs: [600_000],
|
|
168
|
+
abortSignal: controller.signal
|
|
169
|
+
});
|
|
170
|
+
await pauseStarted;
|
|
171
|
+
controller.abort();
|
|
172
|
+
const response = await answer;
|
|
173
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(response.status).toBe(409);
|
|
175
|
+
});
|
|
176
|
+
it('does not wait or ask again when the upload was already cancelled', async () => {
|
|
177
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
controller.abort();
|
|
180
|
+
const response = await requestTusComplete(COMPLETE_URL, init, {
|
|
181
|
+
retryDelaysMs: [600_000],
|
|
182
|
+
abortSignal: controller.signal
|
|
183
|
+
});
|
|
184
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(response.status).toBe(409);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe('uploadChatFileToWidgeticApi (file above the multipart limit)', () => {
|
|
189
|
+
it('finishes the upload when /complete answers at once', async () => {
|
|
190
|
+
fetchMock.mockResolvedValueOnce(completedAnswer());
|
|
191
|
+
const result = await uploadChatFileToWidgeticApi(largeFile(), config);
|
|
192
|
+
expect(result).toEqual({
|
|
193
|
+
id: 'file_1',
|
|
194
|
+
url: 'https://cdn.test/clip.mp4',
|
|
195
|
+
fileType: 'video/mp4',
|
|
196
|
+
fileName: 'clip.mp4',
|
|
197
|
+
fileSize: 10
|
|
198
|
+
});
|
|
199
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
200
|
+
const [calledUrl, calledInit] = fetchMock.mock.calls[0];
|
|
201
|
+
expect(calledUrl).toBe(COMPLETE_URL);
|
|
202
|
+
expect(calledInit).toMatchObject({
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { Authorization: 'Bearer test-token' }
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
it('asks again after 0.5 s and then 1 s when /complete says the upload is busy, then finishes', async () => {
|
|
208
|
+
fetchMock
|
|
209
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
210
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
211
|
+
.mockResolvedValueOnce(completedAnswer());
|
|
212
|
+
const requestedPauses = skipPauses();
|
|
213
|
+
const result = await uploadChatFileToWidgeticApi(largeFile(), config);
|
|
214
|
+
expect(result.id).toBe('file_1');
|
|
215
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
216
|
+
expect(requestedPauses.filter((delayMs) => delayMs >= 500)).toEqual([500, 1000]);
|
|
217
|
+
});
|
|
218
|
+
it('gives up after five more tries and fails with the message of the last answer', async () => {
|
|
219
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
220
|
+
const requestedPauses = skipPauses();
|
|
221
|
+
await expect(uploadChatFileToWidgeticApi(largeFile(), config)).rejects.toThrow(BUSY_MESSAGE);
|
|
222
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
223
|
+
expect(requestedPauses.filter((delayMs) => delayMs >= 500)).toEqual([
|
|
224
|
+
500, 1000, 2000, 4000, 8000
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
it('does not ask again when chunks are missing, and says so', async () => {
|
|
228
|
+
fetchMock.mockResolvedValueOnce(incompleteAnswer());
|
|
229
|
+
const requestedPauses = skipPauses();
|
|
230
|
+
await expect(uploadChatFileToWidgeticApi(largeFile(), config)).rejects.toThrow(INCOMPLETE_MESSAGE);
|
|
231
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
232
|
+
expect(requestedPauses.filter((delayMs) => delayMs >= 500)).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
it('says the upload was cancelled, not that it was busy, when it is cancelled while waiting to ask again', async () => {
|
|
235
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const pauseStarted = new Promise((resolve) => {
|
|
238
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler, delayMs) => {
|
|
239
|
+
// Hold the first pause (0.5 s) open until the cancel arrives.
|
|
240
|
+
if (delayMs === 500) {
|
|
241
|
+
resolve();
|
|
242
|
+
return realSetTimeout(handler, 600_000);
|
|
243
|
+
}
|
|
244
|
+
return realSetTimeout(handler, 0);
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
const pending = uploadChatFileToWidgeticApi(largeFile(), config, {
|
|
248
|
+
abortSignal: controller.signal
|
|
249
|
+
});
|
|
250
|
+
// Keep the rejection from being reported as unhandled before the test reads it.
|
|
251
|
+
const outcome = pending.then(() => 'resolved', (error) => error.message);
|
|
252
|
+
await pauseStarted;
|
|
253
|
+
controller.abort();
|
|
254
|
+
expect(await outcome).toBe('Upload cancelled.');
|
|
255
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
256
|
+
});
|
|
257
|
+
});
|