@widgetic/creator 0.3.75 → 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
@@ -8903,6 +8906,148 @@ Requirements:
8903
8906
  return logs;
8904
8907
  }
8905
8908
 
8909
+ function languageStorageKey(widgetId: string): string {
8910
+ return `widgetic-widget-language:${widgetId}`;
8911
+ }
8912
+
8913
+ function generationOptionsStorageKey(widgetId: string): string {
8914
+ return `widgetic-generation-options:${widgetId}`;
8915
+ }
8916
+
8917
+ type RememberedGenerationOptions = {
8918
+ language: 'svelte' | 'react';
8919
+ librariesEnabled: boolean;
8920
+ libraries: string[];
8921
+ brandKitEnabled: boolean;
8922
+ primary: string;
8923
+ secondary: string;
8924
+ title: string;
8925
+ sub: string;
8926
+ fonts: string[];
8927
+ libraryUploads: { name: string; content: string; encoding: string }[];
8928
+ fontUploads: { name: string; content: string; encoding: string }[];
8929
+ };
8930
+
8931
+ let generationOptionsMemory: Record<string, RememberedGenerationOptions> = {};
8932
+
8933
+ function currentGenerationOptions(): RememberedGenerationOptions {
8934
+ return {
8935
+ language: widgetLanguage === 'react' ? 'react' : 'svelte',
8936
+ librariesEnabled,
8937
+ libraries: [...brandKitLibraries],
8938
+ brandKitEnabled,
8939
+ primary: brandPrimary,
8940
+ secondary: brandSecondary,
8941
+ title: brandTitle,
8942
+ sub: brandSub,
8943
+ fonts: [...brandFonts],
8944
+ libraryUploads: libraryUploads.map((file) => ({ ...file })),
8945
+ fontUploads: fontUploads.map((file) => ({ ...file })),
8946
+ };
8947
+ }
8948
+
8949
+ function rememberGenerationOptions(widgetId: string): void {
8950
+ if (!widgetId) return;
8951
+ const options = currentGenerationOptions();
8952
+ generationOptionsMemory = { ...generationOptionsMemory, [widgetId]: options };
8953
+ try {
8954
+ localStorage.setItem(languageStorageKey(widgetId), options.language);
8955
+ localStorage.setItem(generationOptionsStorageKey(widgetId), JSON.stringify({
8956
+ language: options.language,
8957
+ librariesEnabled: options.librariesEnabled,
8958
+ libraries: options.libraries,
8959
+ brandKitEnabled: options.brandKitEnabled,
8960
+ primary: options.primary,
8961
+ secondary: options.secondary,
8962
+ title: options.title,
8963
+ sub: options.sub,
8964
+ fonts: options.fonts,
8965
+ }));
8966
+ } catch (error) {
8967
+ console.warn('[WidgetCreator] Could not store generation options', error);
8968
+ }
8969
+ }
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
+
9012
+ function restoreGenerationOptions(widgetId: string): void {
9013
+ const inMemory = generationOptionsMemory[widgetId];
9014
+ let stored: Partial<RememberedGenerationOptions> | null = null;
9015
+ if (!inMemory) {
9016
+ try {
9017
+ const raw = localStorage.getItem(generationOptionsStorageKey(widgetId));
9018
+ stored = raw ? JSON.parse(raw) : null;
9019
+ } catch (error) {
9020
+ console.warn('[WidgetCreator] Could not read generation options', error);
9021
+ }
9022
+ }
9023
+ const options = inMemory || stored;
9024
+ const storedLanguage = options?.language === 'svelte' || options?.language === 'react' ? options.language : null;
9025
+ widgetLanguage = languageToShow(widgetId, storedLanguage);
9026
+ librariesEnabled = !!options?.librariesEnabled;
9027
+ brandKitLibraries = Array.isArray(options?.libraries) ? [...options.libraries] : [];
9028
+ brandKitEnabled = !!options?.brandKitEnabled;
9029
+ brandPrimary = options?.primary || '#314F2F';
9030
+ brandSecondary = options?.secondary || '#7BA668';
9031
+ brandTitle = options?.title || '#0F2010';
9032
+ brandSub = options?.sub || '#5C6B58';
9033
+ brandFonts = Array.isArray(options?.fonts) && options.fonts.length > 0 ? [...options.fonts] : ['Inter'];
9034
+ libraryUploads = inMemory?.libraryUploads ? inMemory.libraryUploads.map((file) => ({ ...file })) : [];
9035
+ fontUploads = inMemory?.fontUploads ? inMemory.fontUploads.map((file) => ({ ...file })) : [];
9036
+ }
9037
+
9038
+ function languageForGeneration(widgetId: string): 'svelte' | 'react' {
9039
+ const hasCode = widgetHasGeneratedCode(widgetId);
9040
+ const language: 'svelte' | 'react' = hasCode
9041
+ ? (readGeneratedLanguage(widgetId) ?? 'svelte')
9042
+ : (widgetLanguage === 'svelte' ? 'svelte' : 'react');
9043
+ widgetLanguage = language;
9044
+ rememberGeneratedLanguage(widgetId, language);
9045
+ rememberGenerationOptions(widgetId);
9046
+ return language;
9047
+ }
9048
+
9049
+ $: if (focusedPanelWidgetId) rememberGenerationOptions(focusedPanelWidgetId);
9050
+
8906
9051
  async function performCodeGeneration(
8907
9052
  prompt: string,
8908
9053
  options: {
@@ -9191,6 +9336,7 @@ Requirements:
9191
9336
  compileFixOnly: options.compileFixOnly === true,
9192
9337
  skipIntentGate: options.skipIntentGate === true,
9193
9338
  chipIntent: options.chipIntent,
9339
+ language: languageForGeneration(generationWidgetId),
9194
9340
  brandKit: brandKitForRequest(),
9195
9341
  } as GenerateCodeRequest
9196
9342
  });
@@ -12042,9 +12188,11 @@ Requirements:
12042
12188
  notifyHostWidgetName(getWidgetById(widgetId) ?? selectedWidget);
12043
12189
  return;
12044
12190
  }
12191
+ if (focusedPanelWidgetId) rememberGenerationOptions(focusedPanelWidgetId);
12045
12192
  captureFocusedPanelSnapshot();
12046
12193
  ensurePanelState(widgetId);
12047
12194
  focusedPanelWidgetId = widgetId;
12195
+ restoreGenerationOptions(widgetId);
12048
12196
  raiseWidgetDetailsPanelToFront(widgetId);
12049
12197
  isHistoryOpen = false;
12050
12198
  const widget = getWidgetById(widgetId);
@@ -16869,6 +17017,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
16869
17017
  bind:sub={brandSub}
16870
17018
  bind:fonts={brandFonts}
16871
17019
  showLanguage={debugMode}
17020
+ languageLocked={!!(generatingWidgetIds[panelWidgetId] || ps.lastCommitId || ps.lastPublishedVersion != null)}
16872
17021
  bind:language={widgetLanguage}
16873
17022
  />
16874
17023
  {/snippet}
@@ -31,6 +31,7 @@
31
31
  fontUploads?: { name: string; content: string; encoding: string }[];
32
32
  showLanguage?: boolean;
33
33
  language?: string;
34
+ languageLocked?: boolean;
34
35
  }
35
36
 
36
37
  const maxFonts = 3;
@@ -54,7 +55,8 @@
54
55
  libraryUploads = $bindable([] as { name: string; content: string; encoding: string }[]),
55
56
  fontUploads = $bindable([] as { name: string; content: string; encoding: string }[]),
56
57
  showLanguage = false,
57
- language = $bindable('svelte'),
58
+ language = $bindable('react'),
59
+ languageLocked = false,
58
60
  }: Props = $props();
59
61
 
60
62
  const libraryChoices = [
@@ -281,6 +283,16 @@
281
283
  <div class="generation-libraries-row flex w-full flex-col gap-2">
282
284
  <div class="generation-libraries-checks flex flex-nowrap items-center gap-1">
283
285
  {#if showLanguage}
286
+ {#if languageLocked}
287
+ <HoverTooltip class="inline-flex shrink-0" text="Language is fixed after the first generation">
288
+ <span
289
+ class="generation-language-select flex h-10 w-[4.75rem] shrink-0 cursor-default items-center justify-between gap-0.5 rounded-lg border border-gray-300 bg-white px-1.5 text-sm text-gray-900 opacity-60"
290
+ aria-disabled="true"
291
+ >
292
+ <span class="truncate">{language === 'react' ? 'React' : 'Svelte'}</span>
293
+ </span>
294
+ </HoverTooltip>
295
+ {:else}
284
296
  <HoverTooltip class="inline-flex shrink-0" text="Select language">
285
297
  <DropdownMenu.Root>
286
298
  <DropdownMenu.Trigger
@@ -303,11 +315,14 @@
303
315
  </DropdownMenu.Content>
304
316
  </DropdownMenu.Root>
305
317
  </HoverTooltip>
318
+ {/if}
306
319
  {/if}
320
+ {#if showPresetLibraries}
307
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'}">
308
322
  <input class="generation-option-check cursor-pointer" type="checkbox" checked={librariesEnabled} onchange={onLibrariesChange} />
309
323
  Libraries
310
324
  </label>
325
+ {/if}
311
326
  {#if librariesEnabled && showPresetLibraries}
312
327
  {#each libraryChoices as choice (choice.id)}
313
328
  <label class="generation-library flex items-center gap-1 text-xs text-gray-700">
@@ -321,7 +336,7 @@
321
336
  </label>
322
337
  {/each}
323
338
  {/if}
324
- {#if librariesEnabled}
339
+ {#if librariesEnabled && showPresetLibraries}
325
340
  <HoverTooltip class="inline-flex shrink-0" text="Add Library">
326
341
  <button
327
342
  type="button"
@@ -332,7 +347,7 @@
332
347
  </HoverTooltip>
333
348
  {/if}
334
349
  </div>
335
- {#if librariesEnabled}
350
+ {#if librariesEnabled && showPresetLibraries}
336
351
  {#each libraryRows as row (row.id)}
337
352
  <div class="generation-source-row flex items-center gap-1">
338
353
  <FileUrlSourceControl
@@ -26,6 +26,7 @@ interface Props {
26
26
  }[];
27
27
  showLanguage?: boolean;
28
28
  language?: string;
29
+ languageLocked?: boolean;
29
30
  }
30
31
  declare const BrandKitPanel: import("svelte").Component<Props, {}, "brandKitEnabled" | "librariesEnabled" | "primary" | "secondary" | "title" | "sub" | "fonts" | "libraries" | "libraryGitUrl" | "libraryFileName" | "libraryUploads" | "fontUploads" | "language">;
31
32
  type BrandKitPanel = ReturnType<typeof BrandKitPanel>;
@@ -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,10 +1,10 @@
1
1
  {
2
2
  "name": "@widgetic/creator",
3
- "version": "0.3.75",
3
+ "version": "0.3.77",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@sveltejs/kit": "^2.20.0",
7
- "@widgetic/api-sdk": "^1.0.22",
7
+ "@widgetic/api-sdk": "^1.0.23",
8
8
  "@widgetic/cache-layer": "^0.1.2",
9
9
  "@widgetic/canvas": "^0.5.15",
10
10
  "@widgetic/chat": "^0.1.17",
@@ -68,7 +68,7 @@
68
68
  "@tailwindcss/postcss": "^4.1.4",
69
69
  "@tailwindcss/typography": "^0.5.15",
70
70
  "@types/node": "26.4.0",
71
- "@widgetic/api-sdk": "^1.0.22",
71
+ "@widgetic/api-sdk": "^1.0.23",
72
72
  "@widgetic/cache-layer": "^0.1.2",
73
73
  "@widgetic/canvas": "^0.5.15",
74
74
  "@widgetic/chat": "^0.1.17",