@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.
@@ -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 = 'svelte';
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
- widgetLanguage = options?.language === 'react' ? 'react' : 'svelte';
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 panel = getPanelState(widgetId);
8995
- const hasCode = !!(panel.lastCommitId || panel.lastPublishedVersion != null);
8996
- let language: 'svelte' | 'react' = widgetLanguage === 'react' ? 'react' : 'svelte';
8997
- if (hasCode) {
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('svelte'),
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 fetch(`${endpoint}/${tusId}/complete`, {
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@widgetic/creator",
3
- "version": "0.3.76",
3
+ "version": "0.3.77",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@sveltejs/kit": "^2.20.0",